codebee 0.1.101 → 0.1.102
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.
- package/CHANGELOG.md +9 -0
- package/README.md +8 -7
- package/app/core/acp_client.py +34 -3
- package/app/core/board.py +21 -17
- package/app/core/builtin_agent.py +13 -4
- package/app/core/catalog.py +28 -6
- package/app/core/checkpoints.py +117 -31
- package/app/core/eval_matrix.py +10 -1
- package/app/core/mcp_client.py +7 -2
- package/app/core/openapi.py +8 -0
- package/app/core/pipeline.py +4 -1
- package/app/core/policy.py +9 -0
- package/app/core/publish/auto.py +106 -9
- package/app/core/publish/fanqie.py +16 -10
- package/app/core/publish/flow.py +29 -1
- package/app/core/publish/manager.py +8 -0
- package/app/core/sandbox_runtime.py +95 -0
- package/app/core/store.py +13 -3
- package/app/main.py +1 -0
- package/app/ui/app.js +163 -26
- package/app/ui/hive3d.js +491 -223
- package/app/ui/i18n.js +18 -0
- package/app/ui/index.html +24 -4
- package/app/ui/style.css +548 -18
- package/package.json +1 -1
package/app/core/publish/auto.py
CHANGED
|
@@ -24,6 +24,7 @@ import time
|
|
|
24
24
|
PACE_S = 45 # 章间间隔(防风控节奏),测试里可置 0
|
|
25
25
|
IDLE_POLL_S = 2 # 等 manager busy 结束的轮询步长
|
|
26
26
|
IDLE_TIMEOUT_S = 420 # 单章最长等待(含浏览器操作与人工确认窗口)
|
|
27
|
+
TRANSIENT_RETRY_S = 5 # 连接类瞬断的重试前静默(等浏览器缓过来)
|
|
27
28
|
|
|
28
29
|
_CAP_DEFAULT = 10
|
|
29
30
|
_STREAK_DEFAULT = 3
|
|
@@ -95,6 +96,36 @@ def _fail_window_text():
|
|
|
95
96
|
return ("%g 小时" % h) if h < 24 else ("%g 天" % (h / 24))
|
|
96
97
|
|
|
97
98
|
|
|
99
|
+
# 连接类瞬断标记(2026-10-10 存草稿实案:45 章 fill 时「连接被关闭」整链
|
|
100
|
+
# 停摆)——命中即对该章重试一次再弃,不因浏览器抖动废掉整批
|
|
101
|
+
_TRANSIENT_MARKS = ("连接断开", "连接被关闭", "10053", "10054", "等响应超时",
|
|
102
|
+
"已中止", "目标已关闭")
|
|
103
|
+
# 幂等拒起标记(manager 层台账/实况对账的拒绝话术)——命中即跳过该章,
|
|
104
|
+
# 不是失败
|
|
105
|
+
_IDEMPOTENT_MARKS = ("已成功发布过", "已存过草稿")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _is_transient(err):
|
|
109
|
+
e = str(err or "")
|
|
110
|
+
return any(m in e for m in _TRANSIENT_MARKS)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _is_idempotent_reject(err):
|
|
114
|
+
e = str(err or "")
|
|
115
|
+
return any(m in e for m in _IDEMPOTENT_MARKS)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def other_batch_running(task_id):
|
|
119
|
+
"""除本任务外是否还有别的自动发布/存草稿批次在跑。命中返回对方
|
|
120
|
+
task_id,否则空串——草稿链与之互斥(共用平台浏览器会互踩页签,
|
|
121
|
+
2026-10-10 实案:连载发章占着浏览器,草稿流 fill 被掐断连接)。"""
|
|
122
|
+
with _LOCK:
|
|
123
|
+
for tid, ent in _running.items():
|
|
124
|
+
if tid != str(task_id) and ent.get("status") == "running":
|
|
125
|
+
return str(tid)
|
|
126
|
+
return ""
|
|
127
|
+
|
|
128
|
+
|
|
98
129
|
# ---------------------------------------------------------------- 待发枚举
|
|
99
130
|
# 文档目录里的编号文件不是章节稿:章纲/大纲按章编号(第85章-xxx.md、
|
|
100
131
|
# vol-1-ch-16.md),全按章号算会把待发清单撑出一堆「假章」——批量选择
|
|
@@ -282,7 +313,10 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
|
|
|
282
313
|
as_draft=True(全部发草稿,2026-10-09):逐章自动填稿并点「存草稿」,
|
|
283
314
|
不上线、不受质量闸拦、无需人工确认,一章接一章连跑到清空待发清单
|
|
284
315
|
(章间仍按 PACE_S 防风控节奏);提交发布由用户到平台草稿箱手工完成,
|
|
285
|
-
届时质量闸照常把关。与 auto_submit 互斥(草稿模式忽略 auto_submit)。
|
|
316
|
+
届时质量闸照常把关。与 auto_submit 互斥(草稿模式忽略 auto_submit)。
|
|
317
|
+
草稿链三条专属护栏(2026-10-10 假成功案):起跑前先 attach-only 对账
|
|
318
|
+
平台实况并重算待发(已发布/已存稿剔除,对不了账就拒起);跑批期间
|
|
319
|
+
检测到另一批自动发布即让位停止(平台浏览器互斥)。"""
|
|
286
320
|
from .. import store
|
|
287
321
|
from . import ledger, manager
|
|
288
322
|
if platform not in manager.PLATFORMS:
|
|
@@ -301,6 +335,11 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
|
|
|
301
335
|
cur = _running.get(task_id) or {}
|
|
302
336
|
if cur.get("status") == "running":
|
|
303
337
|
return False, "该任务已有自动发布进行中,请等本轮结束"
|
|
338
|
+
if as_draft:
|
|
339
|
+
other = other_batch_running(task_id)
|
|
340
|
+
if other:
|
|
341
|
+
return False, ("检测到另一批自动发布/存草稿进行中(任务 %s)——两边"
|
|
342
|
+
"共用平台浏览器会互踩,请等它跑完再存草稿" % other)
|
|
304
343
|
task = store.get_task(task_id)
|
|
305
344
|
if not task:
|
|
306
345
|
return False, "任务不存在"
|
|
@@ -329,6 +368,7 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
|
|
|
329
368
|
pend, err = pending(task_id, platform)
|
|
330
369
|
if err:
|
|
331
370
|
return False, err
|
|
371
|
+
want = None
|
|
332
372
|
if only is not None:
|
|
333
373
|
try:
|
|
334
374
|
want = {int(n) for n in (only or []) if n}
|
|
@@ -346,13 +386,49 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
|
|
|
346
386
|
st = {"platform": platform, "at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
347
387
|
"done": 0, "total": len(pend), "status": "running", "error": "",
|
|
348
388
|
"auto_submit": bool(auto_submit), "as_draft": bool(as_draft),
|
|
349
|
-
"last_chapter": 0}
|
|
389
|
+
"last_chapter": 0, "skipped": 0, "notes": []}
|
|
350
390
|
with _LOCK:
|
|
351
391
|
_running[task_id] = st
|
|
352
392
|
|
|
353
393
|
def run():
|
|
354
394
|
try:
|
|
355
|
-
|
|
395
|
+
items = pend
|
|
396
|
+
if as_draft:
|
|
397
|
+
# 起草前对账(2026-10-10 假成功案):台账只记得自己发的章,
|
|
398
|
+
# 手工补交的只有平台知道——attach-only 刷一次校准再重算待发,
|
|
399
|
+
# 已发布/已存稿章就地剔除,不去撞序号重复。对不了账(浏览器
|
|
400
|
+
# 未连接/忙)整个拒起:存草稿本来就需要平台浏览器,宁等空窗
|
|
401
|
+
# 不盲跑;平台没配章节计数时跳过对账维持旧路径。
|
|
402
|
+
ok_r, why_r = manager.sync_published(task_id, platform,
|
|
403
|
+
manual=False)
|
|
404
|
+
if not ok_r and "未配置章节计数" not in str(why_r or ""):
|
|
405
|
+
st["status"] = "error"
|
|
406
|
+
st["error"] = ("存草稿前对账失败:%s。为防把已发布章重复存稿,"
|
|
407
|
+
"本次未起草;请连接平台浏览器并等它空闲后重试"
|
|
408
|
+
% why_r)
|
|
409
|
+
return
|
|
410
|
+
fresh, _ = pending(task_id, platform)
|
|
411
|
+
if want is not None:
|
|
412
|
+
fresh = [p for p in fresh if p["chapter_no"] in want]
|
|
413
|
+
if not fresh:
|
|
414
|
+
st["status"] = "done"
|
|
415
|
+
st["message"] = ("对账后没有可存草稿的章节(所选均已发布或"
|
|
416
|
+
"已存稿);确需重存请先在平台删旧稿")
|
|
417
|
+
return
|
|
418
|
+
if len(fresh) != st["total"]:
|
|
419
|
+
st["notes"].append("对账剔除 %d 章已发布/已存稿,实存 %d 章"
|
|
420
|
+
% (st["total"] - len(fresh), len(fresh)))
|
|
421
|
+
st["total"] = len(fresh)
|
|
422
|
+
items = fresh
|
|
423
|
+
for item in items:
|
|
424
|
+
if as_draft:
|
|
425
|
+
other = other_batch_running(task_id)
|
|
426
|
+
if other:
|
|
427
|
+
st["status"] = "error"
|
|
428
|
+
st["error"] = ("检测到另一批自动发布已启动(任务 %s),"
|
|
429
|
+
"存草稿让位停止;已存 %d 章落平台草稿箱,"
|
|
430
|
+
"等对方跑完再续" % (other, st["done"]))
|
|
431
|
+
return
|
|
356
432
|
g_ok, why = guards(task_id, platform) # 每章前复查(中途也能拦)
|
|
357
433
|
if not g_ok:
|
|
358
434
|
st["status"] = "error"
|
|
@@ -366,6 +442,22 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
|
|
|
366
442
|
ok2, err2 = manager.upload_chapter_async(
|
|
367
443
|
task_id, platform, str(Path(wd) / item["file"]),
|
|
368
444
|
**upload_kwargs)
|
|
445
|
+
if not ok2 and as_draft and _is_idempotent_reject(err2):
|
|
446
|
+
# 幂等拒起=这章已发布/已存过稿:跳过,不是失败——
|
|
447
|
+
# 整链不该因「没活干」停摆
|
|
448
|
+
st["skipped"] += 1
|
|
449
|
+
st["notes"].append("第 %d 章跳过:%s"
|
|
450
|
+
% (item["chapter_no"], str(err2)[:60]))
|
|
451
|
+
continue
|
|
452
|
+
if not ok2 and as_draft and _is_transient(err2):
|
|
453
|
+
# 连接类瞬断(连载流互踩/浏览器抖动):重试一次再弃
|
|
454
|
+
st["notes"].append("第 %d 章连接中断,%d 秒后重试一次"
|
|
455
|
+
% (item["chapter_no"],
|
|
456
|
+
TRANSIENT_RETRY_S))
|
|
457
|
+
time.sleep(TRANSIENT_RETRY_S)
|
|
458
|
+
ok2, err2 = manager.upload_chapter_async(
|
|
459
|
+
task_id, platform, str(Path(wd) / item["file"]),
|
|
460
|
+
**upload_kwargs)
|
|
369
461
|
if not ok2:
|
|
370
462
|
st["status"] = "error"
|
|
371
463
|
st["error"] = "第 %d 章发起失败:%s" % (item["chapter_no"], err2)
|
|
@@ -387,12 +479,15 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
|
|
|
387
479
|
if item["chapter_no"] not in ledger.drafted_chapters(
|
|
388
480
|
task_id, platform):
|
|
389
481
|
st["status"] = "error"
|
|
390
|
-
st["error"] = ("第 %d
|
|
391
|
-
"
|
|
482
|
+
st["error"] = ("第 %d 章存草稿未确认(台账未落草稿账),"
|
|
483
|
+
"后续章节未存;截图存证见发布台账,若实际"
|
|
484
|
+
"已保存成功,把页面反馈文案照截图校准进"
|
|
485
|
+
"流程表 expect_text 步骤"
|
|
486
|
+
% item["chapter_no"])
|
|
392
487
|
return
|
|
393
488
|
st["done"] += 1
|
|
394
489
|
st["last_chapter"] = item["chapter_no"]
|
|
395
|
-
if st["done"] < st["total"]:
|
|
490
|
+
if st["done"] + st["skipped"] < st["total"]:
|
|
396
491
|
time.sleep(PACE_S)
|
|
397
492
|
continue
|
|
398
493
|
if not auto_submit:
|
|
@@ -416,9 +511,11 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
|
|
|
416
511
|
time.sleep(PACE_S)
|
|
417
512
|
st["status"] = "done"
|
|
418
513
|
if as_draft:
|
|
419
|
-
st["message"] = ("已存草稿 %d
|
|
420
|
-
"
|
|
421
|
-
|
|
514
|
+
st["message"] = ("已存草稿 %d 章%s(未发布,不受质量闸拦不代表可"
|
|
515
|
+
"直发);请到平台章节管理/草稿箱逐章检查后提交"
|
|
516
|
+
"发布"
|
|
517
|
+
% (st["done"], ("、跳过 %d 章" % st["skipped"])
|
|
518
|
+
if st["skipped"] else ""))
|
|
422
519
|
elif not auto_submit:
|
|
423
520
|
st["message"] = "第 %d 章已填好,请在浏览器里确认提交,再重新校准" % st["last_chapter"]
|
|
424
521
|
except Exception as e: # 线程内绝不能悬挂无终态
|
|
@@ -110,12 +110,17 @@ UPLOAD_CHAPTER = [
|
|
|
110
110
|
"optional": True},
|
|
111
111
|
]
|
|
112
112
|
|
|
113
|
-
# 发章存草稿(2026-10-09
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
113
|
+
# 发章存草稿(2026-10-09 全部发草稿;2026-10-10 假成功案重构):
|
|
114
|
+
# 与 UPLOAD_CHAPTER 同一张填表前段,终点不同——点编辑器头部「存草稿」直接
|
|
115
|
+
# 落草稿箱,不走「下一步→内容检测→发布提示→提交」发布链。三点教训入表:
|
|
116
|
+
# 1) submit 带 always=true:草稿流程跑在 auto_submit=false 语义下,而这里的
|
|
117
|
+
# 「存草稿」点击本身就是保存动作——被人工闸吞掉时 manager 把「填好未存」
|
|
118
|
+
# 记成成功(44 章假成功实案)。
|
|
119
|
+
# 2) expect_text 等编辑器页保存反馈为主凭据(标记文案以真机校准为准,失败
|
|
120
|
+
# 自动落 fail 截图);「已保存」这类自动保存字样刻意不入标记——编辑器
|
|
121
|
+
# 自动保存不可依赖,不能当本次点击的证据。
|
|
122
|
+
# 3) verify 仍走章节管理页兜底(新章草稿行同样在列表里);对「已发布章重复
|
|
123
|
+
# 起草」它必然假通过——该情形由起草前对账(auto 层)剔除,不靠本表。
|
|
119
124
|
UPLOAD_CHAPTER_DRAFT = [
|
|
120
125
|
{"do": "navigate", "url": "{editor_url}"},
|
|
121
126
|
{"do": "url_any", "any": ["fanqienovel.com"]},
|
|
@@ -131,10 +136,11 @@ UPLOAD_CHAPTER_DRAFT = [
|
|
|
131
136
|
{"do": "wait", "sel": "[contenteditable=true],textarea[class*=content],div[class*=editor]", "timeout": 10},
|
|
132
137
|
{"do": "fill", "sel": "[contenteditable=true],textarea[class*=content]", "key": "chapter_body"},
|
|
133
138
|
{"do": "shot", "name": "chapter-filled-draft"},
|
|
134
|
-
{"do": "submit", "text": "存草稿", "scope": "button", "tries": 15},
|
|
135
|
-
{"do": "
|
|
136
|
-
|
|
137
|
-
{"do": "
|
|
139
|
+
{"do": "submit", "text": "存草稿", "scope": "button", "tries": 15, "always": True},
|
|
140
|
+
{"do": "sleep", "s": 1.2},
|
|
141
|
+
{"do": "shot", "name": "chapter-draft-clicked"},
|
|
142
|
+
{"do": "expect_text",
|
|
143
|
+
"any": ["保存成功", "存草稿成功", "草稿已保存", "已存草稿"], "timeout": 12},
|
|
138
144
|
{"do": "verify", "url": "{chapter_manage_url}",
|
|
139
145
|
"any": ["第{chapter_no}章", "{chapter_name}"], "settle": 6, "new_tab": True},
|
|
140
146
|
]
|
package/app/core/publish/flow.py
CHANGED
|
@@ -558,7 +558,10 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
|
|
|
558
558
|
if log:
|
|
559
559
|
log("PROBE " + json.dumps(info, ensure_ascii=False)[:4000])
|
|
560
560
|
elif act == "submit":
|
|
561
|
-
|
|
561
|
+
# always=true 的提交步(草稿流的「存草稿」)本身就是保存动作,
|
|
562
|
+
# 不吃 auto_submit 人工闸——被闸吞掉时流程停在「填好未存」,
|
|
563
|
+
# manager 却照记成功(2026-10-10 44 章假成功实案)
|
|
564
|
+
if not auto_submit and not st.get("always"):
|
|
562
565
|
note(i, "已填好未提交——请人工检查后提交(auto_submit=false)")
|
|
563
566
|
if shot:
|
|
564
567
|
shot("ready-manual-submit")
|
|
@@ -686,6 +689,31 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
|
|
|
686
689
|
finally:
|
|
687
690
|
if new_tab:
|
|
688
691
|
vpage.close_tab()
|
|
692
|
+
elif act == "expect_text":
|
|
693
|
+
# 页面反馈等待(草稿保存凭据,2026-10-10):轮询页面文本直到
|
|
694
|
+
# 出现任一成功标记(toast/状态字样)——verify 的章节管理页对
|
|
695
|
+
# 「已发布章重复起草」必然假通过,编辑器保存反馈才是本次动作
|
|
696
|
+
# 自己的证据。超时即失败:宁可带 fail 截图拦下,不可把「没
|
|
697
|
+
# 存上」记成成功。标记文案以真机校准为准。
|
|
698
|
+
marks = [str(m) for m in (st.get("any") or [])]
|
|
699
|
+
if not marks:
|
|
700
|
+
continue
|
|
701
|
+
deadline = time.time() + float(st.get("timeout") or 12)
|
|
702
|
+
hit = ""
|
|
703
|
+
while time.time() < deadline:
|
|
704
|
+
body_txt = str(page.call(
|
|
705
|
+
"()=>(document.body.innerText||'')", timeout=15) or "")
|
|
706
|
+
hit = next((m for m in marks if m in body_txt), "")
|
|
707
|
+
if hit:
|
|
708
|
+
break
|
|
709
|
+
time.sleep(0.8)
|
|
710
|
+
if not hit:
|
|
711
|
+
raise FlowError("未见保存成功反馈(等了 %ds,标记:%s)——"
|
|
712
|
+
"若截图里实际已保存成功,请把真实反馈文案"
|
|
713
|
+
"校准进流程表"
|
|
714
|
+
% (int(float(st.get("timeout") or 12)),
|
|
715
|
+
"、".join(marks)[:60]))
|
|
716
|
+
note(i, "反馈命中「%s」" % hit)
|
|
689
717
|
elif act == "volume":
|
|
690
718
|
# 发章带卷(2026-10-08 批量自动发布·自动分卷):确保目标卷
|
|
691
719
|
# 在平台上存在(缺则走「新建分卷」)。平台新章按卷自动归类、
|
|
@@ -897,6 +897,14 @@ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False,
|
|
|
897
897
|
book_ref = dict(book)
|
|
898
898
|
if _st(plat).get("status") == "busy":
|
|
899
899
|
return False, "该平台有操作正在进行中"
|
|
900
|
+
if as_draft:
|
|
901
|
+
# 草稿与别的自动发布批次互斥(2026-10-10 实案:连载发章占着浏览器,
|
|
902
|
+
# 草稿流 fill 被掐断连接)——单章草稿同样不让起
|
|
903
|
+
from . import auto as _auto
|
|
904
|
+
other = _auto.other_batch_running(task_id)
|
|
905
|
+
if other:
|
|
906
|
+
return False, ("检测到另一批自动发布进行中(任务 %s),平台浏览器会"
|
|
907
|
+
"被互踩;等它跑完再存草稿" % other)
|
|
900
908
|
ch_no, title, body, err = read_chapter(chapter_file)
|
|
901
909
|
if err:
|
|
902
910
|
return False, err
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Optional process sandbox launchers.
|
|
2
|
+
|
|
3
|
+
The default CodeBee install has no container dependency. Docker is an explicit
|
|
4
|
+
opt-in backend selected by a task policy and a configured image; this module
|
|
5
|
+
only builds argv and never silently falls back to a weaker backend.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SandboxUnavailable(RuntimeError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def requested_backend(sandbox):
|
|
20
|
+
value = str((sandbox or {}).get("backend") or "native").strip().lower()
|
|
21
|
+
return value or "native"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def docker_argv(command, *, sandbox, workdir, image=None, extra_args=(),
|
|
25
|
+
container_workdir=None,
|
|
26
|
+
container_command=("/bin/sh", "-lc")):
|
|
27
|
+
"""Build a restricted ``docker run`` argv for a command or stdio server."""
|
|
28
|
+
if requested_backend(sandbox) != "docker":
|
|
29
|
+
raise SandboxUnavailable("Docker 后端未被任务策略启用")
|
|
30
|
+
docker = shutil.which("docker")
|
|
31
|
+
if not docker:
|
|
32
|
+
raise SandboxUnavailable("当前系统没有可用的 Docker CLI")
|
|
33
|
+
root = Path(workdir or ".").expanduser().resolve(strict=True)
|
|
34
|
+
image = str(image or (sandbox or {}).get("docker_image") or "").strip()
|
|
35
|
+
if not image or len(image) > 240 or not re.match(r"^[A-Za-z0-9][A-Za-z0-9._/:@-]*$", image):
|
|
36
|
+
raise SandboxUnavailable("Docker 后端必须配置可信镜像")
|
|
37
|
+
roots = []
|
|
38
|
+
for value in (sandbox or {}).get("allowed_roots") or [str(root)]:
|
|
39
|
+
candidate = Path(value).expanduser().resolve(strict=True)
|
|
40
|
+
# Docker currently mounts exactly one root. Require the task workspace
|
|
41
|
+
# itself; narrower roots need a separately resolved container cwd and
|
|
42
|
+
# must never accidentally broaden into an ancestor mount.
|
|
43
|
+
if candidate != root:
|
|
44
|
+
raise SandboxUnavailable("Docker 目前只支持挂载任务工作目录")
|
|
45
|
+
if candidate not in roots:
|
|
46
|
+
roots.append(candidate)
|
|
47
|
+
if not roots:
|
|
48
|
+
raise SandboxUnavailable("Docker 后端没有允许目录")
|
|
49
|
+
selected_root = next((candidate for candidate in roots
|
|
50
|
+
if candidate == root or candidate in root.parents
|
|
51
|
+
or root in candidate.parents), None)
|
|
52
|
+
if selected_root is None:
|
|
53
|
+
raise SandboxUnavailable("当前工作目录不在 Docker 允许目录内")
|
|
54
|
+
if len(roots) != 1:
|
|
55
|
+
raise SandboxUnavailable("Docker 暂只支持一个明确的允许目录")
|
|
56
|
+
argv = [docker, "run", "--rm", "--init", "--read-only",
|
|
57
|
+
"--security-opt", "no-new-privileges", "--cap-drop=ALL",
|
|
58
|
+
"--ipc=none", "--pids-limit", "256", "--memory", "2g", "--cpus", "2",
|
|
59
|
+
"--tmpfs", "/tmp:rw,noexec,nosuid,size=64m"]
|
|
60
|
+
if hasattr(os, "getuid") and hasattr(os, "getgid"):
|
|
61
|
+
argv += ["--user", "%d:%d" % (os.getuid(), os.getgid())]
|
|
62
|
+
else:
|
|
63
|
+
argv += ["--user", "65534:65534"]
|
|
64
|
+
argv += ["--network", "none" if not bool((sandbox or {}).get("network", True)) else "bridge"]
|
|
65
|
+
for name in (sandbox or {}).get("env_allowlist") or []:
|
|
66
|
+
name = str(name)
|
|
67
|
+
if name and name in os.environ:
|
|
68
|
+
# Value is supplied through the filtered Docker client environment;
|
|
69
|
+
# never place credentials in argv, where process/log inspection sees them.
|
|
70
|
+
argv += ["--env", name]
|
|
71
|
+
for candidate in roots:
|
|
72
|
+
if "," in str(candidate):
|
|
73
|
+
raise SandboxUnavailable("Docker 允许目录包含不支持的逗号")
|
|
74
|
+
argv += ["--mount", "type=bind,src=%s,dst=/workspace" % str(candidate)]
|
|
75
|
+
if container_workdir is None:
|
|
76
|
+
rel_workdir = root.relative_to(selected_root).as_posix()
|
|
77
|
+
container_workdir = "/workspace" + ("/" + rel_workdir if rel_workdir != "." else "")
|
|
78
|
+
if not str(container_workdir).startswith("/") or ".." in Path(container_workdir).parts:
|
|
79
|
+
raise SandboxUnavailable("Docker 容器工作目录无效")
|
|
80
|
+
argv += ["--workdir", str(container_workdir), *list(extra_args), image]
|
|
81
|
+
if isinstance(command, (list, tuple)):
|
|
82
|
+
argv += [str(x) for x in command]
|
|
83
|
+
else:
|
|
84
|
+
argv += list(container_command) + [str(command)]
|
|
85
|
+
return argv
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def docker_env(sandbox):
|
|
89
|
+
"""Docker receives only explicitly allowed host environment values."""
|
|
90
|
+
out = {"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
|
91
|
+
"HOME": "/tmp", "TMPDIR": "/tmp"}
|
|
92
|
+
for name in (sandbox or {}).get("env_allowlist") or []:
|
|
93
|
+
if name in os.environ:
|
|
94
|
+
out[str(name)] = os.environ[str(name)]
|
|
95
|
+
return out
|
package/app/core/store.py
CHANGED
|
@@ -145,8 +145,15 @@ def create_task(payload):
|
|
|
145
145
|
goal = _text(payload.get("goal"), "goal")
|
|
146
146
|
workdir = _text(payload.get("workdir"), "workdir")
|
|
147
147
|
if not goal:
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
# 扫榜选材的菜单承诺「方向可留空,默认分析总榜热门题材」——
|
|
149
|
+
# paihang.rank_scan_prompt 与 pipeline 两侧均已支持空 goal,这里放行;
|
|
150
|
+
# 其余类型目标必填不变。
|
|
151
|
+
if payload.get("type") == "rank_scan":
|
|
152
|
+
title = title or flow.get("name") or "扫榜选材"
|
|
153
|
+
else:
|
|
154
|
+
raise ValueError("目标描述不能为空")
|
|
155
|
+
else:
|
|
156
|
+
title = title or goal.splitlines()[0][:30] # 标题可省略,自动取目标首行
|
|
150
157
|
if not workdir:
|
|
151
158
|
# 未指定目录 → 用「默认保存路径」(设置里可改;内置回落 <数据目录>/workspace)。
|
|
152
159
|
# 默认路径允许自动创建;用户显式给的目录仍必须已存在。
|
|
@@ -2128,7 +2135,7 @@ def clear_stream_state(run_id=None, n=None):
|
|
|
2128
2135
|
|
|
2129
2136
|
def finish_step(run_id, n, status, summary="", exit_code=None,
|
|
2130
2137
|
cost_usd=0.0, tokens=0.0, duration_s=None, model=None, output=None,
|
|
2131
|
-
followups=None, thinking=None, partial=False):
|
|
2138
|
+
followups=None, thinking=None, partial=False, acp_events=None):
|
|
2132
2139
|
with LOCK:
|
|
2133
2140
|
run = _RUNS.get(run_id)
|
|
2134
2141
|
if not run:
|
|
@@ -2163,6 +2170,9 @@ def finish_step(run_id, n, status, summary="", exit_code=None,
|
|
|
2163
2170
|
s["partial"] = True
|
|
2164
2171
|
else:
|
|
2165
2172
|
s.pop("partial", None)
|
|
2173
|
+
if acp_events:
|
|
2174
|
+
s["acp_events"] = [dict(item) for item in acp_events[:200]
|
|
2175
|
+
if isinstance(item, dict)]
|
|
2166
2176
|
# 收尾即清运行中态:stream/activity/live 只是过程快照,留着会让
|
|
2167
2177
|
# 前端把「已结束」的步骤仍当实时流渲染(也白占 run.json 体积)
|
|
2168
2178
|
s.pop("stream", None)
|
package/app/main.py
CHANGED
|
@@ -2848,6 +2848,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
2848
2848
|
"thinking": s.get("thinking") or "",
|
|
2849
2849
|
"stream": (s.get("stream") or "") if running else "",
|
|
2850
2850
|
"activity": (s.get("activity") or []) if running else [],
|
|
2851
|
+
"acp_events": s.get("acp_events") or [],
|
|
2851
2852
|
"live": s.get("live") or 0,
|
|
2852
2853
|
})
|
|
2853
2854
|
|