sycommon-python-lib 0.2.4a7__py3-none-any.whl → 0.2.5a0__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.
@@ -289,15 +289,26 @@ class FileOperationsMixin:
289
289
  self: "HTTPSandboxBackend",
290
290
  file_path: str,
291
291
  content: str,
292
+ *,
293
+ overwrite: bool = True, # 默认允许覆盖,兼容 deepagents offload 场景
292
294
  ) -> WriteResult:
293
- """写入文件"""
295
+ """写入文件
296
+
297
+ Args:
298
+ file_path: 文件路径。
299
+ content: 文件内容。
300
+ overwrite: 是否允许覆盖已有文件,默认 True(兼容 deepagents 0.6.3
301
+ SummarizationMiddleware offload 场景,如 /large_tool_results/ 和
302
+ /conversation_history/ 路径)。设为 False 时,文件已存在返回错误。
303
+ """
294
304
  try:
295
305
  self._ensure_synced_sync()
296
306
  SYLogger.info(f"[Sandbox] 写入文件: {file_path} ({len(content)} 字符)")
297
307
  result = self._post_sync(f"{SANDBOX_API_PREFIX}/write", {
298
308
  "file_path": file_path,
299
309
  "content": base64.b64encode(content.encode()).decode(),
300
- "user_id": self.user_id
310
+ "user_id": self.user_id,
311
+ "overwrite": overwrite,
301
312
  })
302
313
  write_result = WriteResult(
303
314
  error=result.get("error"),
@@ -317,16 +328,25 @@ class FileOperationsMixin:
317
328
  file_path: str,
318
329
  content: str,
319
330
  *,
331
+ overwrite: bool = True, # 默认允许覆盖,兼容 deepagents offload 场景
320
332
  timeout: int = None,
321
333
  ) -> WriteResult:
322
- """异步写入文件"""
334
+ """异步写入文件
335
+
336
+ Args:
337
+ file_path: 文件路径。
338
+ content: 文件内容。
339
+ overwrite: 是否允许覆盖已有文件,默认 True。
340
+ timeout: 请求超时时间。
341
+ """
323
342
  try:
324
343
  await self._ensure_synced_async()
325
344
  SYLogger.info(f"[Sandbox] 异步写入文件: {file_path} ({len(content)} 字符)")
326
345
  result = await self._post_async_with_failover(f"{SANDBOX_API_PREFIX}/write", {
327
346
  "file_path": file_path,
328
347
  "content": base64.b64encode(content.encode()).decode(),
329
- "user_id": self.user_id
348
+ "user_id": self.user_id,
349
+ "overwrite": overwrite,
330
350
  }, timeout=timeout)
331
351
  write_result = WriteResult(
332
352
  error=result.get("error"),
@@ -133,7 +133,20 @@ WORKSPACE_BASE = os.environ.get("SANDBOX_WORKSPACE", _default_workspace)
133
133
  # ============== 辅助函数 ==============
134
134
 
135
135
  def _build_init_script(workspace: str) -> str:
136
- """构建 shell 初始化脚本(沙箱环境初始化 + 路径映射 + 资源限制)"""
136
+ """构建 shell 初始化脚本(沙箱环境初始化 + 路径映射 + 资源限制)
137
+
138
+ Linux/macOS: 使用 bash
139
+ Windows: 使用 PowerShell 兼容模式
140
+ """
141
+ if platform.system() == "Windows":
142
+ # Windows: 用 PowerShell 简化版(无 bash)
143
+ safe_workspace = workspace.replace("'", "''")
144
+ return f'''
145
+ $env:SANDBOX_ROOT = '{safe_workspace}'
146
+ $env:_SANDBOX_WORKSPACE = '{safe_workspace}'
147
+ Set-Location '{safe_workspace}'
148
+ '''
149
+
137
150
  safe_workspace = shlex.quote(workspace)
138
151
  return f'''
139
152
  # 沙箱环境初始化
@@ -232,10 +245,38 @@ async def aensure_subdirs(workspace: str, subdirs: list[str] = None):
232
245
  await asyncio.to_thread(_makedirs)
233
246
 
234
247
 
248
+ def _to_virtual_path(abs_path: str, workspace: str) -> str:
249
+ """将本地绝对路径转换为 POSIX 虚拟路径(用于返回给客户端/LLM)。
250
+
251
+ 始终返回 "/" 开头的 POSIX 格式路径,无论当前操作系统。
252
+ 例: /Users/.../workspace/skills/foo.md → "/skills/foo.md"
253
+ """
254
+ from pathlib import PurePosixPath, PurePath
255
+ rel = PurePath(abs_path).relative_to(PurePath(workspace))
256
+ return "/" + str(PurePosixPath(*rel.parts))
257
+
258
+
259
+ def _is_path_within(child: str, parent: str) -> bool:
260
+ """检查 child 路径是否在 parent 目录内,跨平台兼容。
261
+
262
+ 使用 os.path.realpath 解析后比较,避免 os.sep 差异(Windows = \\,Linux = /)
263
+ 以及 symlink 导致的逃逸。
264
+ """
265
+ child_resolved = os.path.realpath(child)
266
+ parent_resolved = os.path.realpath(parent)
267
+ return child_resolved.startswith(parent_resolved + os.sep) or child_resolved == parent_resolved
268
+
269
+
235
270
  def resolve_sandbox_path(path: str, workspace: str) -> str:
236
271
  """
237
272
  将沙箱路径解析到用户工作目录,实现路径隔离
238
273
 
274
+ 跨平台兼容:
275
+ - 输入路径始终为 POSIX 格式(/skills/foo.md),来自客户端/LLM
276
+ - 输出路径为当前 OS 的本地格式(Linux: /data/.../skills/foo.md, Windows: C:/.../skills/foo.md)
277
+ - 使用 pathlib.PurePosixPath 标准化输入,避免 os.path.normpath 在 Windows 上
278
+ 不正确处理 POSIX 前导 / 的问题
279
+
239
280
  所有路径(包括绝对路径)都会映射到用户工作目录下:
240
281
  - /skills/foo.md -> {workspace}/skills/foo.md
241
282
  - skills/foo.md -> {workspace}/skills/foo.md
@@ -244,28 +285,76 @@ def resolve_sandbox_path(path: str, workspace: str) -> str:
244
285
 
245
286
  同时防止路径遍历攻击,确保解析后的路径仍在工作目录内
246
287
  """
247
- # 规范化路径
248
- path = os.path.normpath(path)
288
+ from pathlib import PurePosixPath, Path
289
+
249
290
  workspace = os.path.normpath(workspace)
250
291
 
251
- # 如果路径已经在工作目录内,直接返回
252
- if path.startswith(workspace + os.sep) or path == workspace:
253
- return path
292
+ # 如果路径已经是本地绝对路径且在 workspace 内,直接返回
293
+ path_normed = os.path.normpath(path)
294
+ if os.path.isabs(path_normed) and _is_path_within(path_normed, workspace):
295
+ return path_normed
254
296
 
255
- # 移除开头的 / 使其成为相对路径
256
- if os.path.isabs(path):
257
- path = path.lstrip(os.sep)
297
+ # 输入是 POSIX 虚拟路径(如 /skills/foo.md),用 PurePosixPath 解析
298
+ # PurePosixPath 会自动去掉前导 / 得到相对部分
299
+ posix_path = PurePosixPath(path)
300
+ if posix_path.is_absolute():
301
+ # 去掉前导 / 得到相对路径字符串(如 skills/foo.md)
302
+ # parts[0] 是 '/',取 parts[1:] 拼接
303
+ relative_str = str(PurePosixPath(*posix_path.parts[1:])) if len(posix_path.parts) > 1 else ""
304
+ else:
305
+ relative_str = str(posix_path)
258
306
 
259
- # 拼接到工作目录
260
- resolved = os.path.normpath(os.path.join(workspace, path))
307
+ # 用 os.path.join 拼接到本地 workspace(自动适配 os.sep)
308
+ resolved = os.path.normpath(os.path.join(workspace, relative_str))
261
309
 
262
310
  # 安全检查:确保最终路径在工作目录内
263
- if not resolved.startswith(workspace + os.sep) and resolved != workspace:
311
+ if not _is_path_within(resolved, workspace):
264
312
  raise ValueError(f"Path traversal detected: {path}")
265
313
 
266
314
  return resolved
267
315
 
268
316
 
317
+ # Windows 不支持 start_new_session 和 os.killpg,用条件常量
318
+ _SUBPROCESS_NEW_SESSION = platform.system() != "Windows"
319
+
320
+
321
+ def _kill_process_tree(process):
322
+ """杀掉进程及其子进程,跨平台兼容。
323
+
324
+ Linux/macOS: os.killpg 杀进程组(包括孙进程)
325
+ Windows: process.kill() 杀主进程
326
+ """
327
+ if platform.system() == "Windows":
328
+ try:
329
+ process.kill()
330
+ except (ProcessLookupError, PermissionError):
331
+ pass
332
+ else:
333
+ _kill_process_tree(process)
334
+
335
+
336
+ def _build_execute_command(init_script: str, user_command: str) -> tuple[str, dict]:
337
+ """构建跨平台的执行命令。
338
+
339
+ Returns:
340
+ (full_command, kwargs) - 完整命令字符串和 subprocess 额外参数。
341
+ """
342
+ if platform.system() == "Windows":
343
+ # Windows: PowerShell 执行
344
+ full_command = f'''{init_script}
345
+ {user_command}
346
+ '''
347
+ return full_command, {"shell": True}
348
+ else:
349
+ # Linux/macOS: bash heredoc
350
+ full_command = f'''bash <<'COMMAND_EOF'
351
+ {init_script}
352
+ {user_command}
353
+ COMMAND_EOF
354
+ '''
355
+ return full_command, {}
356
+
357
+
269
358
  def setup_sandbox_handler(app: FastAPI, config: dict = None):
270
359
  """
271
360
  沙箱服务初始化
@@ -337,13 +426,8 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
337
426
  # 使用统一初始化脚本
338
427
  init_script = _build_init_script(workspace)
339
428
 
340
- # 使用 heredoc 格式执行命令,避免引号转义问题
341
- # <<'COMMAND_EOF' 表示 heredoc 内容不进行变量展开,保护命令中的特殊字符
342
- full_command = f'''bash <<'COMMAND_EOF'
343
- {init_script}
344
- {req.command}
345
- COMMAND_EOF
346
- '''
429
+ # 构建跨平台执行命令
430
+ full_command, _ = _build_execute_command(init_script, req.command)
347
431
 
348
432
  # 设置环境变量
349
433
  env = os.environ.copy()
@@ -360,7 +444,7 @@ COMMAND_EOF
360
444
  stderr=asyncio.subprocess.PIPE,
361
445
  cwd=workspace,
362
446
  env=env,
363
- start_new_session=True,
447
+ start_new_session=_SUBPROCESS_NEW_SESSION,
364
448
  )
365
449
 
366
450
  try:
@@ -373,10 +457,7 @@ COMMAND_EOF
373
457
  stderr = stderr.decode('utf-8') if stderr else ''
374
458
  except asyncio.TimeoutError:
375
459
  # 杀掉整个进程组(包括孙进程)
376
- try:
377
- os.killpg(os.getpgid(process.pid), signal.SIGKILL)
378
- except (ProcessLookupError, PermissionError):
379
- process.kill()
460
+ _kill_process_tree(process)
380
461
  stdout, stderr = await process.communicate()
381
462
  stdout = stdout.decode('utf-8') if stdout else ''
382
463
  stderr = stderr.decode('utf-8') if stderr else ''
@@ -437,11 +518,7 @@ COMMAND_EOF
437
518
  await aensure_subdirs(workspace)
438
519
 
439
520
  init_script = _build_init_script(workspace)
440
- full_command = f'''bash <<'COMMAND_EOF'
441
- {init_script}
442
- {req.command}
443
- COMMAND_EOF
444
- '''
521
+ full_command, _ = _build_execute_command(init_script, req.command)
445
522
 
446
523
  env = os.environ.copy()
447
524
  env["TMPDIR"] = os.path.join(workspace, "tmp")
@@ -457,7 +534,7 @@ COMMAND_EOF
457
534
  stderr=asyncio.subprocess.PIPE,
458
535
  cwd=workspace,
459
536
  env=env,
460
- start_new_session=True,
537
+ start_new_session=_SUBPROCESS_NEW_SESSION,
461
538
  )
462
539
 
463
540
  queue = asyncio.Queue()
@@ -492,10 +569,7 @@ COMMAND_EOF
492
569
  try:
493
570
  exit_code = await asyncio.wait_for(process.wait(), timeout=5)
494
571
  except asyncio.TimeoutError:
495
- try:
496
- os.killpg(os.getpgid(process.pid), signal.SIGKILL)
497
- except (ProcessLookupError, PermissionError):
498
- process.kill()
572
+ _kill_process_tree(process)
499
573
  exit_code = -9
500
574
 
501
575
  yield f"data: {json.dumps({'type': 'done', 'exit_code': exit_code})}\n\n"
@@ -523,11 +597,7 @@ COMMAND_EOF
523
597
  # 使用统一初始化脚本
524
598
  init_script = _build_init_script(workspace)
525
599
 
526
- full_command = f'''bash <<'COMMAND_EOF'
527
- {init_script}
528
- {req.command}
529
- COMMAND_EOF
530
- '''
600
+ full_command, _ = _build_execute_command(init_script, req.command)
531
601
 
532
602
  env = os.environ.copy()
533
603
  env["TMPDIR"] = os.path.join(workspace, "tmp")
@@ -543,7 +613,7 @@ COMMAND_EOF
543
613
  stderr=asyncio.subprocess.PIPE,
544
614
  cwd=workspace,
545
615
  env=env,
546
- start_new_session=True,
616
+ start_new_session=_SUBPROCESS_NEW_SESSION,
547
617
  )
548
618
 
549
619
  async with lock:
@@ -571,10 +641,7 @@ COMMAND_EOF
571
641
  stdout = stdout.decode('utf-8') if stdout else ''
572
642
  stderr = stderr.decode('utf-8') if stderr else ''
573
643
  except asyncio.TimeoutError:
574
- try:
575
- os.killpg(os.getpgid(process.pid), signal.SIGKILL)
576
- except (ProcessLookupError, PermissionError):
577
- process.kill()
644
+ _kill_process_tree(process)
578
645
  stdout, stderr = await process.communicate()
579
646
  stdout = stdout.decode('utf-8') if stdout else ''
580
647
  stderr = stderr.decode('utf-8') if stderr else ''
@@ -711,10 +778,7 @@ COMMAND_EOF
711
778
 
712
779
  # 在线程锁外执行 kill 操作(可能阻塞)
713
780
  try:
714
- try:
715
- os.killpg(os.getpgid(process.pid), signal.SIGKILL)
716
- except (ProcessLookupError, PermissionError):
717
- process.kill()
781
+ _kill_process_tree(process)
718
782
  try:
719
783
  await asyncio.wait_for(process.wait(), timeout=5)
720
784
  except asyncio.TimeoutError:
@@ -770,10 +834,7 @@ COMMAND_EOF
770
834
  for pid, info in to_kill:
771
835
  process = info["process"]
772
836
  try:
773
- try:
774
- os.killpg(os.getpgid(process.pid), signal.SIGKILL)
775
- except (ProcessLookupError, PermissionError):
776
- process.kill()
837
+ _kill_process_tree(process)
777
838
  try:
778
839
  await asyncio.wait_for(process.wait(), timeout=5)
779
840
  except asyncio.TimeoutError:
@@ -808,7 +869,11 @@ COMMAND_EOF
808
869
 
809
870
  @sandbox_router.post("/ls", response_model=list[FileInfo])
810
871
  async def ls_info(req: LsRequest):
811
- """列出目录内容"""
872
+ """列出目录内容
873
+
874
+ 安全增强(对齐 deepagents 0.6.3):
875
+ - symlink 逃逸防护:结果路径 resolve 后必须在 workspace 内
876
+ """
812
877
  workspace = await aget_user_workspace(req.user_id)
813
878
  try:
814
879
  target_path = resolve_sandbox_path(req.path, workspace)
@@ -817,6 +882,7 @@ COMMAND_EOF
817
882
  return []
818
883
 
819
884
  def _ls():
885
+ workspace_resolved = os.path.realpath(workspace)
820
886
  if not os.path.isdir(target_path):
821
887
  return None
822
888
  results = []
@@ -824,7 +890,14 @@ COMMAND_EOF
824
890
  with os.scandir(target_path) as it:
825
891
  for entry in it:
826
892
  abs_path = os.path.join(target_path, entry.name)
827
- virtual_path = "/" + os.path.relpath(abs_path, workspace)
893
+ # symlink 逃逸防护
894
+ try:
895
+ resolved = os.path.realpath(abs_path)
896
+ if not _is_path_within(resolved, workspace_resolved):
897
+ continue
898
+ except OSError:
899
+ continue
900
+ virtual_path = _to_virtual_path(abs_path, workspace)
828
901
  info = FileInfo(path=virtual_path)
829
902
  try:
830
903
  info.is_dir = entry.is_dir(follow_symlinks=False)
@@ -922,7 +995,12 @@ COMMAND_EOF
922
995
 
923
996
  @sandbox_router.post("/write", response_model=WriteResponse)
924
997
  async def write_file(req: WriteRequest):
925
- """写入新文件(文件已存在时返回错误,应使用 edit 替代)"""
998
+ """写入文件
999
+
1000
+ 默认行为:文件不存在时创建新文件。
1001
+ 当 req.overwrite=True 时允许覆盖已有文件(用于 deepagents 内部 offload 场景,
1002
+ 如 /large_tool_results/ 和 /conversation_history/ 路径)。
1003
+ """
926
1004
  workspace = await aget_user_workspace(req.user_id)
927
1005
  try:
928
1006
  file_path = resolve_sandbox_path(req.file_path, workspace)
@@ -933,7 +1011,9 @@ COMMAND_EOF
933
1011
  SYLogger.info(f"[Sandbox Server] 写入文件: {file_path}")
934
1012
 
935
1013
  def _write():
936
- if os.path.exists(file_path):
1014
+ # 允许覆盖模式(用于 deepagents offload 场景)
1015
+ overwrite = getattr(req, 'overwrite', False)
1016
+ if os.path.exists(file_path) and not overwrite:
937
1017
  SYLogger.warning(f"[Sandbox Server] 文件已存在: {file_path}")
938
1018
  return WriteResponse(error=f"Error: File already exists at {req.file_path}. Use edit_file to modify existing files.", path=None)
939
1019
 
@@ -946,9 +1026,10 @@ COMMAND_EOF
946
1026
  with open(file_path, 'w') as f:
947
1027
  f.write(content)
948
1028
 
1029
+ action = "覆盖" if (os.path.exists(file_path) and overwrite) else "新建"
949
1030
  SYLogger.info(
950
- f"[Sandbox Server] 写入成功(新建): {file_path} ({len(content)} 字符)")
951
- virtual_path = "/" + os.path.relpath(file_path, workspace)
1031
+ f"[Sandbox Server] 写入成功({action}): {file_path} ({len(content)} 字符)")
1032
+ virtual_path = _to_virtual_path(file_path, workspace)
952
1033
  return WriteResponse(error=None, path=virtual_path)
953
1034
 
954
1035
  try:
@@ -959,7 +1040,15 @@ COMMAND_EOF
959
1040
 
960
1041
  @sandbox_router.post("/edit", response_model=EditResponse)
961
1042
  async def edit_file(req: EditRequest):
962
- """编辑文件"""
1043
+ """编辑文件
1044
+
1045
+ 增强错误处理(对齐 deepagents 0.6.3 BaseSandbox):
1046
+ - os.stat + S_ISREG 精确判断文件类型
1047
+ - try-except 包裹整个操作,捕获 FileNotFoundError/PermissionError
1048
+ - CRLF 自动适配:匹配文件实际换行风格,保留原始行尾
1049
+ """
1050
+ import stat as stat_mod
1051
+
963
1052
  workspace = await aget_user_workspace(req.user_id)
964
1053
  try:
965
1054
  file_path = resolve_sandbox_path(req.file_path, workspace)
@@ -973,18 +1062,52 @@ COMMAND_EOF
973
1062
  old_string = base64.b64decode(req.old_string).decode('utf-8')
974
1063
  new_string = base64.b64decode(req.new_string).decode('utf-8')
975
1064
 
976
- if not os.path.isfile(file_path):
1065
+ try:
1066
+ st = os.stat(file_path)
1067
+ if not stat_mod.S_ISREG(st.st_mode):
1068
+ SYLogger.error(f"[Sandbox Server] 编辑失败: 不是普通文件 {file_path}")
1069
+ return EditResponse(error="not_a_file")
1070
+ except FileNotFoundError:
977
1071
  SYLogger.error(f"[Sandbox Server] 编辑失败: 文件不存在 {file_path}")
978
- return EditResponse(error=f"File '{file_path}' not found")
1072
+ return EditResponse(error="file_not_found")
1073
+ except PermissionError:
1074
+ SYLogger.error(f"[Sandbox Server] 编辑失败: 权限不足 {file_path}")
1075
+ return EditResponse(error="permission_denied")
979
1076
 
980
- with open(file_path, 'r') as f:
981
- content = f.read()
1077
+ with open(file_path, 'rb') as f:
1078
+ raw = f.read()
982
1079
 
983
- count = content.count(old_string)
1080
+ try:
1081
+ content = raw.decode('utf-8')
1082
+ except UnicodeDecodeError:
1083
+ SYLogger.error(f"[Sandbox Server] 编辑失败: 非文本文件 {file_path}")
1084
+ return EditResponse(error="not_a_text_file")
1085
+
1086
+ # CRLF 自适应匹配(对齐 deepagents 0.6.3):
1087
+ # read endpoint 读取时会将 CRLF 转为 LF 给 LLM,
1088
+ # 所以 old_string 到达时是 LF-only,但文件实际可能是 CRLF。
1089
+ # 尝试多种变体,第一个匹配的揭示了文件在该区域的换行风格。
1090
+ old_crlf = old_string.replace('\r\n', '\n').replace('\n', '\r\n')
1091
+ old_lf = old_string.replace('\r\n', '\n')
1092
+ new_crlf = new_string.replace('\r\n', '\n').replace('\n', '\r\n')
1093
+ new_lf = new_string.replace('\r\n', '\n')
1094
+
1095
+ matched_old, matched_new, count = old_string, new_string, 0
1096
+ for cand_old, cand_new in (
1097
+ (old_string, new_string),
1098
+ (old_crlf, new_crlf),
1099
+ (old_lf, new_lf),
1100
+ ):
1101
+ c = content.count(cand_old)
1102
+ if c >= 1:
1103
+ matched_old, matched_new, count = cand_old, cand_new, c
1104
+ break
984
1105
 
985
1106
  if count == 0:
986
- SYLogger.error(f"[Sandbox Server] 编辑失败: 未找到匹配字符串")
987
- return EditResponse(error=f"String not found: '{old_string[:50]}...'")
1107
+ SYLogger.error("[Sandbox Server] 编辑失败: 未找到匹配字符串")
1108
+ return EditResponse(
1109
+ error=f"String not found: '{old_string[:50]}...'"
1110
+ )
988
1111
  if count > 1 and not req.replace_all:
989
1112
  SYLogger.error(f"[Sandbox Server] 编辑失败: 多处匹配 ({count}次)")
990
1113
  return EditResponse(
@@ -992,15 +1115,15 @@ COMMAND_EOF
992
1115
  )
993
1116
 
994
1117
  if req.replace_all:
995
- new_content = content.replace(old_string, new_string)
1118
+ new_content = content.replace(matched_old, matched_new)
996
1119
  else:
997
- new_content = content.replace(old_string, new_string, 1)
1120
+ new_content = content.replace(matched_old, matched_new, 1)
998
1121
 
999
- with open(file_path, 'w') as f:
1000
- f.write(new_content)
1122
+ with open(file_path, 'wb') as f:
1123
+ f.write(new_content.encode('utf-8'))
1001
1124
 
1002
1125
  SYLogger.info(f"[Sandbox Server] 编辑成功: {file_path} (替换 {count} 处)")
1003
- virtual_path = "/" + os.path.relpath(file_path, workspace)
1126
+ virtual_path = _to_virtual_path(file_path, workspace)
1004
1127
  return EditResponse(error=None, path=virtual_path, occurrences=count)
1005
1128
 
1006
1129
  try:
@@ -1159,7 +1282,7 @@ COMMAND_EOF
1159
1282
  for entry in entries:
1160
1283
 
1161
1284
  abs_path = os.path.join(dir_path, entry.name)
1162
- virtual_path = "/" + os.path.relpath(abs_path, workspace)
1285
+ virtual_path = _to_virtual_path(abs_path, workspace)
1163
1286
  file_count += 1
1164
1287
 
1165
1288
  try:
@@ -1181,7 +1304,7 @@ COMMAND_EOF
1181
1304
 
1182
1305
  return node
1183
1306
 
1184
- root_virtual = "/" + os.path.relpath(target_path, workspace)
1307
+ root_virtual = _to_virtual_path(target_path, workspace)
1185
1308
  return build_tree(target_path, root_virtual, 0)
1186
1309
 
1187
1310
  try:
@@ -1197,7 +1320,12 @@ COMMAND_EOF
1197
1320
 
1198
1321
  @sandbox_router.post("/glob", response_model=list[FileInfo])
1199
1322
  async def glob_files(req: GlobRequest):
1200
- """glob 搜索文件"""
1323
+ """glob 搜索文件
1324
+
1325
+ 增强错误处理(对齐 deepagents 0.6.3 BaseSandbox):
1326
+ - FileNotFoundError / NotADirectoryError / PermissionError 捕获
1327
+ - os.stat 异常时跳过单个条目
1328
+ """
1201
1329
  import glob as glob_module
1202
1330
 
1203
1331
  workspace = await aget_user_workspace(req.user_id)
@@ -1210,17 +1338,32 @@ COMMAND_EOF
1210
1338
  full_pattern = os.path.join(search_path, req.pattern)
1211
1339
 
1212
1340
  def _glob():
1213
- matches = sorted(glob_module.glob(full_pattern, recursive=True))
1341
+ workspace_resolved = os.path.realpath(workspace)
1342
+ try:
1343
+ matches = sorted(glob_module.glob(full_pattern, recursive=True))
1344
+ except (FileNotFoundError, NotADirectoryError, PermissionError) as e:
1345
+ SYLogger.warning(f"[Sandbox Server] glob 异常: {e}")
1346
+ return []
1347
+
1214
1348
  results = []
1215
1349
  for m in matches:
1216
- virtual_path = "/" + os.path.relpath(m, workspace)
1350
+ # 路径安全检查:跳过逃逸出 workspace symlink
1351
+ try:
1352
+ if not _is_path_within(m, workspace_resolved):
1353
+ SYLogger.warning(f"[Sandbox Server] glob 跳过逃逸路径: {m}")
1354
+ continue
1355
+ except OSError:
1356
+ continue
1357
+
1358
+ virtual_path = _to_virtual_path(m, workspace)
1217
1359
  info = FileInfo(path=virtual_path)
1218
1360
  try:
1361
+ st = os.stat(m)
1219
1362
  info.is_dir = os.path.isdir(m)
1220
1363
  if not info.is_dir:
1221
- info.size = os.path.getsize(m)
1222
- except Exception:
1223
- pass
1364
+ info.size = st.st_size
1365
+ except OSError:
1366
+ continue
1224
1367
  results.append(info)
1225
1368
  return results
1226
1369
 
@@ -1228,7 +1371,13 @@ COMMAND_EOF
1228
1371
 
1229
1372
  @sandbox_router.post("/grep", response_model=list[GrepMatch])
1230
1373
  async def grep_search(req: GrepRequest):
1231
- """grep 搜索内容"""
1374
+ """grep 搜索内容
1375
+
1376
+ 安全增强(对齐 deepagents 0.6.3 backends/filesystem.py):
1377
+ - symlink 逃逸防护:结果路径必须 resolve 到 workspace 内
1378
+ - 路径规范化:统一转换为虚拟路径,防止泄露宿主绝对路径
1379
+ - 结果解析全量异步:os.path.realpath/relpath 通过 asyncio.to_thread 执行
1380
+ """
1232
1381
  workspace = await aget_user_workspace(req.user_id)
1233
1382
  try:
1234
1383
  search_path = resolve_sandbox_path(req.path or "/", workspace)
@@ -1250,44 +1399,56 @@ COMMAND_EOF
1250
1399
  cmd,
1251
1400
  stdout=asyncio.subprocess.PIPE,
1252
1401
  stderr=asyncio.subprocess.PIPE,
1253
- start_new_session=True,
1402
+ start_new_session=_SUBPROCESS_NEW_SESSION,
1254
1403
  )
1255
1404
  stdout, _ = await proc.communicate()
1256
1405
  result_text = stdout.decode('utf-8') if stdout else ''
1257
1406
 
1258
- matches = []
1259
- for line in result_text.strip().split("\n"):
1260
- if not line:
1261
- continue
1262
- # 使用更稳健的解析方式:找到第一个冒号(行号前)和第二个冒号(内容前)
1263
- # 先获取行号和内容部分(路径:path:line:text)
1264
- # 格式: path:line:text(text 可能包含冒号)
1265
- first_colon = line.find(":")
1266
- if first_colon == -1:
1267
- continue
1268
- second_colon = line.find(":", first_colon + 1)
1269
- if second_colon == -1:
1270
- continue
1271
-
1272
- path_part = line[:first_colon]
1273
- # 将绝对路径转换为虚拟路径
1274
- try:
1275
- virtual_path_part = "/" + os.path.relpath(path_part, workspace)
1276
- except ValueError:
1277
- virtual_path_part = path_part
1278
- try:
1279
- line_num = int(line[first_colon + 1:second_colon])
1280
- except ValueError:
1281
- continue
1282
- text_part = line[second_colon + 1:]
1407
+ # 结果解析(含 os.path.realpath / relpath 同步 IO)放入线程池
1408
+ def _parse_grep_results():
1409
+ workspace_resolved = os.path.realpath(workspace)
1410
+ matches = []
1411
+ for line in result_text.strip().split("\n"):
1412
+ if not line:
1413
+ continue
1414
+ first_colon = line.find(":")
1415
+ if first_colon == -1:
1416
+ continue
1417
+ second_colon = line.find(":", first_colon + 1)
1418
+ if second_colon == -1:
1419
+ continue
1420
+
1421
+ path_part = line[:first_colon]
1422
+
1423
+ # 路径安全检查(对齐 deepagents 0.6.3):
1424
+ # resolve 后必须在 workspace 内,跳过 symlink 逃逸的结果
1425
+ try:
1426
+ resolved = os.path.realpath(path_part)
1427
+ if not _is_path_within(resolved, workspace_resolved):
1428
+ SYLogger.warning(f"[Sandbox Server] grep 跳过逃逸路径: {path_part}")
1429
+ continue
1430
+ except OSError:
1431
+ continue
1432
+
1433
+ # 将绝对路径转换为虚拟路径
1434
+ try:
1435
+ virtual_path_part = _to_virtual_path(path_part, workspace)
1436
+ except ValueError:
1437
+ virtual_path_part = path_part
1438
+ try:
1439
+ line_num = int(line[first_colon + 1:second_colon])
1440
+ except ValueError:
1441
+ continue
1442
+ text_part = line[second_colon + 1:]
1283
1443
 
1284
- matches.append(GrepMatch(
1285
- path=virtual_path_part,
1286
- line=line_num,
1287
- text=text_part
1288
- ))
1444
+ matches.append(GrepMatch(
1445
+ path=virtual_path_part,
1446
+ line=line_num,
1447
+ text=text_part
1448
+ ))
1449
+ return matches
1289
1450
 
1290
- return matches
1451
+ return await asyncio.to_thread(_parse_grep_results)
1291
1452
 
1292
1453
  @sandbox_router.post("/stat", response_model=StatResponse)
1293
1454
  async def stat_file(req: StatRequest):
@@ -1298,7 +1459,7 @@ COMMAND_EOF
1298
1459
  except ValueError as e:
1299
1460
  return StatResponse(error=str(e))
1300
1461
 
1301
- virtual_path = "/" + os.path.relpath(target_path, workspace)
1462
+ virtual_path = _to_virtual_path(target_path, workspace)
1302
1463
 
1303
1464
  def _stat():
1304
1465
  if not os.path.exists(target_path):
@@ -1337,7 +1498,7 @@ COMMAND_EOF
1337
1498
  try:
1338
1499
  for item in os.listdir(target_path):
1339
1500
  item_path = os.path.join(target_path, item)
1340
- virtual_path = "/" + os.path.relpath(item_path, workspace)
1501
+ virtual_path = _to_virtual_path(item_path, workspace)
1341
1502
  info = FileInfo(path=virtual_path)
1342
1503
  try:
1343
1504
  info.is_dir = os.path.isdir(item_path)
@@ -1366,8 +1527,7 @@ COMMAND_EOF
1366
1527
  return LsResponse(files=files, warning=f"Results truncated at {MAX_FILES} files")
1367
1528
 
1368
1529
  filepath = os.path.join(root, filename)
1369
- virtual_filepath = "/" + \
1370
- os.path.relpath(filepath, workspace)
1530
+ virtual_filepath = _to_virtual_path(filepath, workspace)
1371
1531
  file_info = FileInfo(path=virtual_filepath)
1372
1532
  try:
1373
1533
  file_info.is_dir = False
@@ -1537,7 +1697,7 @@ COMMAND_EOF
1537
1697
  continue
1538
1698
 
1539
1699
  is_dir = new[2] if new else (old[2] if old else False)
1540
- virtual = "/" + os.path.relpath(os.path.join(target_path, p), workspace)
1700
+ virtual = _to_virtual_path(os.path.join(target_path, p), workspace)
1541
1701
  events.append(WatchEvent(event_type=evt, path=virtual, is_dir=is_dir))
1542
1702
 
1543
1703
  with open(snapshot_file, 'w') as f:
@@ -8,6 +8,10 @@
8
8
 
9
9
  同时处理 list 类型 content(如 read_file 读取图片返回的 base64 数据),
10
10
  将其转换为字符串描述,防止上游 API 拒绝 list content 的 400 错误。
11
+
12
+ head+tail 预览模式(对齐 deepagents 0.6.3 _message_eviction):
13
+ 截断时保留内容头部和尾部若干行,中间用行数提示替代,
14
+ 帮助模型了解整体结构而不必 read_file 恢复全部内容。
11
15
  """
12
16
 
13
17
  from collections.abc import Awaitable, Callable
@@ -36,18 +40,68 @@ DEFAULT_PASSTHROUGH_TOOLS: FrozenSet[str] = frozenset({
36
40
  "web_search", # web_search 已在工具内部自行截断
37
41
  })
38
42
 
39
- # 截断提示模板
40
- DEFAULT_TRUNCATION_SUFFIX = (
41
- "\n\n[输出已截断至前{kept}字符,原始长度{original}字符。"
43
+ # head+tail 预览截断模板(对齐 deepagents 0.6.3 TOO_LARGE_TOOL_MSG 风格)
44
+ HEAD_TAIL_TRUNCATION_TEMPLATE = (
45
+ "[输出已截断,原始长度 {original} 字符 / {original_lines} 行。]"
46
+ "\n\n以下是输出的头部和尾部预览(中间省略的行用 `... [N lines truncated] ...` 标记):\n\n"
47
+ "{preview}\n\n"
42
48
  "如需查看完整输出,可以:\n"
43
49
  "1. 重新执行命令并添加过滤条件(如 grep、head、tail)\n"
44
- "2. 将输出重定向到文件后用 read_file 分段读取]"
50
+ "2. 将输出重定向到文件后用 read_file 分段读取(使用 offset 和 limit 参数)"
45
51
  )
46
52
 
47
53
  # list 类型 content 中 base64 图片的最大字符数
48
54
  MAX_IMAGE_BASE64_CHARS = 500
49
55
 
50
56
 
57
+ def _create_head_tail_preview(
58
+ content: str,
59
+ *,
60
+ head_lines: int = 10,
61
+ tail_lines: int = 10,
62
+ max_line_chars: int = 200,
63
+ ) -> str:
64
+ """创建 head+tail 预览,中间用行数提示替代。
65
+
66
+ 对齐 deepagents 0.6.3 的 _create_content_preview 逻辑:
67
+ - 保留头部和尾部若干行
68
+ - 每行截断到 max_line_chars 防止单行爆炸
69
+ - 中间用 `... [N lines truncated] ...` 标记
70
+ - 附带行号帮助模型定位
71
+
72
+ Args:
73
+ content: 原始内容。
74
+ head_lines: 保留头部行数。
75
+ tail_lines: 保留尾部行数。
76
+ max_line_chars: 每行最大字符数。
77
+
78
+ Returns:
79
+ 格式化后的预览字符串。
80
+ """
81
+ lines = content.splitlines()
82
+ total = len(lines)
83
+
84
+ if total <= head_lines + tail_lines + 5:
85
+ # 内容足够短,直接返回(每行截断)
86
+ return "\n".join(line[:max_line_chars] for line in lines)
87
+
88
+ head = [line[:max_line_chars] for line in lines[:head_lines]]
89
+ tail = [line[:max_line_chars] for line in lines[-tail_lines:]]
90
+ omitted = total - head_lines - tail_lines
91
+
92
+ head_with_nums = []
93
+ for i, line in enumerate(head, start=1):
94
+ head_with_nums.append(f"{i:6d} | {line}")
95
+
96
+ truncation_marker = f"\n... [{omitted} lines truncated] ...\n"
97
+
98
+ tail_with_nums = []
99
+ for i, line in enumerate(tail, start=total - tail_lines + 1):
100
+ tail_with_nums.append(f"{i:6d} | {line}")
101
+
102
+ return "\n".join(head_with_nums) + truncation_marker + "\n".join(tail_with_nums)
103
+
104
+
51
105
  def _convert_list_content_to_str(content: list, tool_name: str) -> str:
52
106
  """将 list 类型的 ToolMessage.content 转换为字符串。
53
107
 
@@ -80,12 +134,18 @@ def _convert_list_content_to_str(content: list, tool_name: str) -> str:
80
134
  class ToolResultTruncationMiddleware(AgentMiddleware):
81
135
  """截断过长的工具结果,防止超出模型上下文窗口。
82
136
 
137
+ 采用 head+tail 预览模式(对齐 deepagents 0.6.3 _message_eviction):
138
+ - 保留内容头部和尾部若干行
139
+ - 中间省略部分用行数标记替代
140
+ - 附带行号帮助模型定位和恢复
141
+
83
142
  Args:
84
143
  max_content_length: 工具结果内容的最大字符长度,默认 15000(约 3750 token)。
85
144
  模型上下文 200K token,70% 触发压缩 = 140K token,给单条工具结果留 3750 token
86
145
  约占剩余空间的 2.6%,多条工具调用并行时也不会逼近上限。
87
146
  passthrough_tools: 不截断的工具名集合。
88
- truncation_suffix: 截断提示文本,支持 {kept} 和 {original} 占位符。
147
+ head_lines: 截断时保留的头部行数,默认 10。
148
+ tail_lines: 截断时保留的尾部行数,默认 10。
89
149
  """
90
150
 
91
151
  def __init__(
@@ -93,11 +153,13 @@ class ToolResultTruncationMiddleware(AgentMiddleware):
93
153
  *,
94
154
  max_content_length: int = 15000,
95
155
  passthrough_tools: FrozenSet[str] | None = None,
96
- truncation_suffix: str = DEFAULT_TRUNCATION_SUFFIX,
156
+ head_lines: int = 10,
157
+ tail_lines: int = 10,
97
158
  ) -> None:
98
159
  self._max_content_length = max_content_length
99
160
  self._passthrough_tools = passthrough_tools or DEFAULT_PASSTHROUGH_TOOLS
100
- self._truncation_suffix = truncation_suffix
161
+ self._head_lines = head_lines
162
+ self._tail_lines = tail_lines
101
163
 
102
164
  @property
103
165
  def name(self) -> str:
@@ -114,13 +176,32 @@ class ToolResultTruncationMiddleware(AgentMiddleware):
114
176
  if original_len <= self._max_content_length:
115
177
  return content
116
178
 
117
- kept = self._max_content_length
118
- suffix = self._truncation_suffix.format(kept=kept, original=original_len)
119
- truncated = content[:kept] + suffix
179
+ # head+tail 预览模式
180
+ preview = _create_head_tail_preview(
181
+ content,
182
+ head_lines=self._head_lines,
183
+ tail_lines=self._tail_lines,
184
+ )
185
+ original_lines = len(content.splitlines())
186
+ truncated = HEAD_TAIL_TRUNCATION_TEMPLATE.format(
187
+ original=original_len,
188
+ original_lines=original_lines,
189
+ preview=preview,
190
+ )
191
+
192
+ # 最终安全兜底:如果 head+tail 拼出来还是超长,硬截断
193
+ if len(truncated) > self._max_content_length * 2:
194
+ kept = self._max_content_length
195
+ truncated = content[:kept] + (
196
+ f"\n\n[输出已截断至前{kept}字符,原始长度{original_len}字符。"
197
+ "如需查看完整输出,可以:\n"
198
+ "1. 重新执行命令并添加过滤条件(如 grep、head、tail)\n"
199
+ "2. 将输出重定向到文件后用 read_file 分段读取]"
200
+ )
120
201
 
121
202
  SYLogger.info(
122
203
  f"[ToolResultTruncation] tool='{tool_name}' truncated: "
123
- f"{original_len} -> {len(truncated)} chars"
204
+ f"{original_len} -> {len(truncated)} chars (head+tail preview)"
124
205
  )
125
206
  return truncated
126
207
 
@@ -138,6 +138,7 @@ class WriteRequest(BaseModel):
138
138
  file_path: str
139
139
  content: str # base64 encoded
140
140
  user_id: str
141
+ overwrite: bool = False # 是否允许覆盖已有文件(用于 offload 场景)
141
142
 
142
143
 
143
144
  class WriteResponse(BaseModel):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sycommon-python-lib
3
- Version: 0.2.4a7
3
+ Version: 0.2.5a0
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -8,8 +8,8 @@ Requires-Dist: aio-pika>=9.6.2
8
8
  Requires-Dist: aiohttp>=3.13.5
9
9
  Requires-Dist: aiomysql>=0.3.2
10
10
  Requires-Dist: anyio>=4.12.1
11
- Requires-Dist: decorator>=5.3.0
12
- Requires-Dist: deepagents>=0.6.1
11
+ Requires-Dist: decorator>=5.3.1
12
+ Requires-Dist: deepagents>=0.6.3
13
13
  Requires-Dist: elasticsearch>=9.4.0
14
14
  Requires-Dist: fastapi>=0.136.1
15
15
  Requires-Dist: jinja2>=3.1.6
@@ -136,7 +136,7 @@ sycommon/agent/mcp/__init__.py,sha256=iKrdDhIrFsNIkqG_kgcwNe-nOiM6uVfolKv44LfQ-F
136
136
  sycommon/agent/mcp/models.py,sha256=RBAIbGETNXkqD3wQZT7eKS4ozkgE9DQEneF1WKZf1C0,1355
137
137
  sycommon/agent/mcp/tool_loader.py,sha256=SEny14f7Bm9I17pT-9PJWMbhi9Ki77wvCR0KRNEJmyM,6428
138
138
  sycommon/agent/sandbox/__init__.py,sha256=jR7LlkD4J4Y6QYyRXQClkwmqDBCCPmycV_hQV9p9YHw,4621
139
- sycommon/agent/sandbox/file_ops.py,sha256=6ymRMM0WchM7G_YmF1ckrLjf5s_JCh1wrAp2g_-sg8k,23162
139
+ sycommon/agent/sandbox/file_ops.py,sha256=0PEhmK1OcMq1Qe33JmQwphj670Tih7HQLNuAb1snIjI,24028
140
140
  sycommon/agent/sandbox/http_sandbox_backend.py,sha256=kwuPEmrOMyxfrRu20AEGqWD9t38L-DrtKSFp6CWt44o,56877
141
141
  sycommon/agent/sandbox/minio_sync.py,sha256=d1kuWllvyAvAMsFZCP0OdHEQtXN9BEIgHbupC31BjSk,20000
142
142
  sycommon/agent/sandbox/sandbox_pool.py,sha256=eMn8sLakCWf90l6ni2-333QM8oBdX1CflV-WzneFp_k,9133
@@ -203,10 +203,10 @@ sycommon/middleware/exception.py,sha256=UAy0tKijI_2JoKjwT3h62aL-tybftP3IETvcr26N
203
203
  sycommon/middleware/middleware.py,sha256=z3-6KHdBt93TOSNn0LY579WReSCo0o4f9d9-dq7c-ic,1600
204
204
  sycommon/middleware/monitor_memory.py,sha256=pYRK-wRuDd6enSg9Pf8tQxPdYQS6S0AyjyXeKFRLKEs,628
205
205
  sycommon/middleware/mq.py,sha256=9X6KKtadFjBXKS5L3kEKujYio9wwGfWgXwWOAHO-HDg,254
206
- sycommon/middleware/sandbox.py,sha256=SQnUvVE0gvBo_YrP4fdA-6ZmJxAG9Po1zUynNFrgTI4,61109
206
+ sycommon/middleware/sandbox.py,sha256=DPvG1PhR57O4azvekS1C3XZ-lsS6yvOI5BKDDza9deI,68216
207
207
  sycommon/middleware/timeout.py,sha256=KlxOPa8xl2dg6yuRi_EzkVJG8bX4stb5ueYxctzzGM8,1433
208
208
  sycommon/middleware/token_tracking.py,sha256=rEbgV1bgWMdzAERx4aq5XAvOIT6jTY_tK1P0xHJnL3o,6609
209
- sycommon/middleware/tool_result_truncation.py,sha256=1IfhEwrOHzeSS-MB-43EOts2V_KYAq4T_utvqkhTdbE,7005
209
+ sycommon/middleware/tool_result_truncation.py,sha256=C5739a3HkpiEPLplTnBTGcVOUvkd-lQBl3oYsM0_7rI,10129
210
210
  sycommon/middleware/traceid.py,sha256=HX4zg7Tp_mPNr2eWDDvK3f7GFJ6eOSeW062Xcc-xshc,14340
211
211
  sycommon/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
212
212
  sycommon/models/base_http.py,sha256=EICAAibx3xhjBsLqm35Mi3DCqxp0FME4rD_3iQVjT_E,3051
@@ -214,7 +214,7 @@ sycommon/models/log.py,sha256=rZpj6VkDRxK3B6H7XSeWdYZshU8F0Sks8bq1p6pPlDw,500
214
214
  sycommon/models/mqlistener_config.py,sha256=japihb4kmP5ZqvqQEs6C7CmcrPlE1UmhAtCimoL61xI,1704
215
215
  sycommon/models/mqmsg_model.py,sha256=Zo-LsDMFuF1Vkx9ZmwBC9E7TrCw-7nAQD2TE4v9-6F4,291
216
216
  sycommon/models/mqsend_config.py,sha256=NQX9dc8PpuquMG36GCVhJe8omAW1KVXXqr6lSRU6D7I,268
217
- sycommon/models/sandbox.py,sha256=5QPLqKCDm0UeyC8CHb2G0d7Rb3f7MYaLq12RFRSeOxc,7293
217
+ sycommon/models/sandbox.py,sha256=bW273k59UndtAgUTQEIrZ4oL_ZaaO0pco1THtU_dj2M,7382
218
218
  sycommon/models/sso_user.py,sha256=uqLlZevVaDQQche5eBq8m1b8lu-45Pe4vHY3kd3NdxA,2896
219
219
  sycommon/models/token_usage.py,sha256=w3Aash7jUVSUIgKxBUzI6SqJ9VV5Pk7rLBbMUlLv6nA,1242
220
220
  sycommon/models/token_usage_mysql.py,sha256=eWnCvI2FDwyTA_fr4wWQtAmUnFJgLwrt0WXMXHLAESQ,2633
@@ -265,8 +265,8 @@ sycommon/tools/syemail.py,sha256=BDFhgf7WDOQeTcjxJEQdu0dQhnHFPO_p3eI0-Ni3LhQ,561
265
265
  sycommon/tools/timing.py,sha256=OiiE7P07lRoMzX9kzb8sZU9cDb0zNnqIlY5pWqHcnkY,2064
266
266
  sycommon/xxljob/__init__.py,sha256=7eoBlQxv-B39IfRSCY2bkqdGYs1QRe1umAWd88VMEEM,86
267
267
  sycommon/xxljob/xxljob_service.py,sha256=JIEJaGXhqrTLcyxlyynSrsHg9bBnDNzX-D4qIWLRPUE,6815
268
- sycommon_python_lib-0.2.4a7.dist-info/METADATA,sha256=ReaCw3hM57OWF1Gso6vD-CXssScGQMrJl3f7u78z0uM,7879
269
- sycommon_python_lib-0.2.4a7.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
270
- sycommon_python_lib-0.2.4a7.dist-info/entry_points.txt,sha256=gsR4SssKxDWjRU8ggidzNcdMXDPRSKRS7UaGyNP84Qg,92
271
- sycommon_python_lib-0.2.4a7.dist-info/top_level.txt,sha256=RgphKrg7nJyZ7irJqbxFr-5H2LUYTvI7ivoWZH2hcD0,29
272
- sycommon_python_lib-0.2.4a7.dist-info/RECORD,,
268
+ sycommon_python_lib-0.2.5a0.dist-info/METADATA,sha256=Nr9thCmAZmPdc2M2phRRYCQ6rOCzvak_fQd42DuWaRM,7879
269
+ sycommon_python_lib-0.2.5a0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
270
+ sycommon_python_lib-0.2.5a0.dist-info/entry_points.txt,sha256=gsR4SssKxDWjRU8ggidzNcdMXDPRSKRS7UaGyNP84Qg,92
271
+ sycommon_python_lib-0.2.5a0.dist-info/top_level.txt,sha256=RgphKrg7nJyZ7irJqbxFr-5H2LUYTvI7ivoWZH2hcD0,29
272
+ sycommon_python_lib-0.2.5a0.dist-info/RECORD,,