codebee 0.1.100 → 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 +17 -0
- package/README.md +8 -5
- 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 +86 -5
- package/app/core/plugins.py +9 -2
- 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 +31 -4
- package/app/main.py +1 -0
- package/app/ui/app.js +164 -27
- 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/pipeline.py
CHANGED
|
@@ -586,6 +586,8 @@ def _external_sandbox_block_reason(agent, workdir, sandbox):
|
|
|
586
586
|
from . import policy
|
|
587
587
|
root = os.path.realpath(workdir or ".")
|
|
588
588
|
normalized = policy.normalize_sandbox(sandbox, root)
|
|
589
|
+
if normalized.get("backend") == "docker":
|
|
590
|
+
return "沙箱拒绝:Docker 目前仅接入内置命令工具,外部 CLI 未验证容器启动,拒绝运行"
|
|
589
591
|
if (agent or {}).get("kind") == "codex":
|
|
590
592
|
if normalized.get("network") is False:
|
|
591
593
|
return "沙箱拒绝:该 CLI 后端未接入网络隔离,拒绝运行网络受限任务"
|
|
@@ -912,7 +914,8 @@ def _finish_step_result(run_id, step, res, role, agent, start):
|
|
|
912
914
|
followups=res.get("followups"),
|
|
913
915
|
# 思考过程(内置智能体流式抓取):落进步骤记录,对话气泡折叠展示
|
|
914
916
|
thinking=res.get("reasoning") or None,
|
|
915
|
-
partial=bool(res.get("partial"))
|
|
917
|
+
partial=bool(res.get("partial")),
|
|
918
|
+
acp_events=(raw.get("acp_events") or []))
|
|
916
919
|
try:
|
|
917
920
|
checkpoint_status = ("unknown" if raw.get("unknown") else
|
|
918
921
|
"timeout" if status == "timeout" else
|
|
@@ -2591,6 +2594,36 @@ __CRITIQUE__
|
|
|
2591
2594
|
- 针对性改进所有 major 问题;保持既定风格与设定。
|
|
2592
2595
|
- 完成后用 3 句话说明本轮改了什么。"""
|
|
2593
2596
|
|
|
2597
|
+
# 评审口径单一真源:评审面提示词(单章/全局)与作者面注入块从这里同源提取,
|
|
2598
|
+
# 两边永不脱节。此前评审清单/签约专项/全局口径只活在评审面,作者第一遍写完
|
|
2599
|
+
# 被打回才知道按什么标准被审(2026-10-10 实案:全章过线仍卡全局 major)。
|
|
2600
|
+
# 作者面刻意不下发评分机制本体(1-10 分/阈值/宁严勿宽)——对写作者零价值,
|
|
2601
|
+
# 透下去只喂响应试素材;清单条目本身全是质量正向要求,事前下发只有好处。
|
|
2602
|
+
REVIEW_CHECKLIST_ITEMS = (
|
|
2603
|
+
"叙述语域与视角统一",
|
|
2604
|
+
"复杂情感落到动作、五感或生理细节",
|
|
2605
|
+
"开篇在前 600 字进入具体冲突",
|
|
2606
|
+
"每章发生真实的局面变化",
|
|
2607
|
+
"无可删的重复铺垫与长段解释",
|
|
2608
|
+
)
|
|
2609
|
+
GLOBAL_REVIEW_FOCUS = ("主线一致性、人物弧光、开篇吸引力、文风表现、情感表达、"
|
|
2610
|
+
"情节推进与节奏控制")
|
|
2611
|
+
|
|
2612
|
+
|
|
2613
|
+
def author_review_rubric_block(dims, signing_dims):
|
|
2614
|
+
"""作者面评审口径块:起草/修订提示词里事前下发「会按什么标准被审」,
|
|
2615
|
+
与评审面同源(REVIEW_CHECKLIST_ITEMS / GLOBAL_REVIEW_FOCUS)。静态文本,
|
|
2616
|
+
同批各章字节一致,不碎前缀缓存。"""
|
|
2617
|
+
lines = [
|
|
2618
|
+
"## 评审口径(本书会按以下标准审稿,写作时对照自查)",
|
|
2619
|
+
"- 评分维度:" + " / ".join(dims) + "。",
|
|
2620
|
+
"- 逐项硬清单:" + ";".join(REVIEW_CHECKLIST_ITEMS) + "。",
|
|
2621
|
+
"- 全书级关注:" + GLOBAL_REVIEW_FOCUS + "。",
|
|
2622
|
+
"- 签约专项维度:" + "、".join(signing_dims) + "。",
|
|
2623
|
+
]
|
|
2624
|
+
return "\n".join(lines) + "\n"
|
|
2625
|
+
|
|
2626
|
+
|
|
2594
2627
|
NOVEL_CRITIQUE_PROMPT = """你是严格的评审(不要使用任何工具、不要修改文件,只依据下方稿件内容评审)。
|
|
2595
2628
|
请输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
|
|
2596
2629
|
{
|
|
@@ -2599,7 +2632,7 @@ NOVEL_CRITIQUE_PROMPT = """你是严格的评审(不要使用任何工具、
|
|
|
2599
2632
|
"summary": "一句话总评"
|
|
2600
2633
|
}
|
|
2601
2634
|
每个维度打 1-10 分(可为小数),宁严勿宽。major 问题必须给 quote(系统会逐条校验引文是否真在稿件中——编造的引文会被降档标记)。
|
|
2602
|
-
|
|
2635
|
+
评审逐项核查以下各项是否成立:%s。""" % (";".join(REVIEW_CHECKLIST_ITEMS)) + """
|
|
2603
2636
|
|
|
2604
2637
|
## 待评审稿件
|
|
2605
2638
|
---
|
|
@@ -2885,6 +2918,7 @@ __VOLUME__
|
|
|
2885
2918
|
- 本章主要情绪/戏剧变化:__HIGHLIGHT__(按其重要性决定篇幅和表现方式;不强制放大,不为满足标签伪造高潮)
|
|
2886
2919
|
__LOCKED_CH__
|
|
2887
2920
|
__STALE_NOTE__
|
|
2921
|
+
__REDO_ISSUES__
|
|
2888
2922
|
- 正文约 __WORDS__ 字,中文,直接开写正文(可含本章标题行)。
|
|
2889
2923
|
|
|
2890
2924
|
## 前情提要(此前各章结尾摘录,衔接用)
|
|
@@ -2899,6 +2933,7 @@ __STYLE__
|
|
|
2899
2933
|
## 签约质量门禁
|
|
2900
2934
|
__QUALITY_GATE__
|
|
2901
2935
|
|
|
2936
|
+
__REVIEW_RUBRIC__
|
|
2902
2937
|
- 写完文件后,最终回复只输出一行:`第 __I__ 章完成(约 __WORDS__ 字)`——不要在回复里复述或解释正文。"""
|
|
2903
2938
|
|
|
2904
2939
|
SERIAL_REVISE_PROMPT = """你是一名网文作者。第 __I__ 章没有通过评审,请修订文件 `__FILE__`(直接改写该文件)。文件必须以 UTF-8 编码保存(PowerShell 显式加 -Encoding UTF8,禁止依赖默认编码)。
|
|
@@ -2913,6 +2948,7 @@ __BEATS__
|
|
|
2913
2948
|
(本章爽点/情绪爆点:__HIGHLIGHT__)
|
|
2914
2949
|
__LOCKED_CH__
|
|
2915
2950
|
|
|
2951
|
+
__REVIEW_RUBRIC__
|
|
2916
2952
|
## 本章评审意见
|
|
2917
2953
|
__CRITIQUE__
|
|
2918
2954
|
|
|
@@ -2936,7 +2972,7 @@ SERIAL_GLOBAL_PROMPT = """你是独立小说质量评审(不要修改任何文
|
|
|
2936
2972
|
"issues": [{"dim": "维度名", "severity": "major|minor", "note": "具体问题(指明哪一章)", "quote": "支撑该问题的稿件原文连续片段(≥8字,逐字摘录不许改写)"}],
|
|
2937
2973
|
"summary": "一句话总评:所读范围内的文本优势、主要风险与未验证项;不得预测签约结果"
|
|
2938
2974
|
}
|
|
2939
|
-
每个维度打 1-10 分,按统一文本 rubric
|
|
2975
|
+
每个维度打 1-10 分,按统一文本 rubric 与所读证据评估,不以“宁严勿宽”压分。重点关注:%s。""" % GLOBAL_REVIEW_FOCUS + """
|
|
2940
2976
|
若是全书最初的前 3 章,可观察视角人物、故事承诺、开篇兑现和读者理解成本;固定字数点、钩子/爽点密度仅作参考,不是扣分门槛。发现问题须给可定位原文引文,并区分文本事实、解释和建议。
|
|
2941
2977
|
major 问题必须给 quote(系统会逐条校验引文是否真在稿件中——编造的引文会被降档标记)。
|
|
2942
2978
|
|
|
@@ -3342,6 +3378,8 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
3342
3378
|
# 签约专项维度作为额外硬性检查注入提示词与报告证据。
|
|
3343
3379
|
dims = task.get("rubric") or DEFAULT_RUBRIC
|
|
3344
3380
|
signing_dims = novel_quality.rubric_for({"type": "serial_novel"})
|
|
3381
|
+
# 作者面评审口径块(与评审面同源提取):起草/修订/打磨提示词都带
|
|
3382
|
+
rubric_block = author_review_rubric_block(dims, signing_dims)
|
|
3345
3383
|
threshold = task.get("threshold", 7.0)
|
|
3346
3384
|
threshold_ch = threshold - 0.5 if threshold >= 7.5 else threshold # 单章阈值略放宽 0.5 分
|
|
3347
3385
|
dimkey = ", ".join('"%s": 0' % d for d in dims)
|
|
@@ -3412,6 +3450,9 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
3412
3450
|
inherit = run.get("inherit") or {}
|
|
3413
3451
|
done_set = set(inherit.get("done_chapters") or [])
|
|
3414
3452
|
inh_scores = {c.get("chapter"): c for c in (inherit.get("chapter_scores") or [])}
|
|
3453
|
+
# 未达标重写的原因账(store.retry_task 从 verdict.major_issues 下发):
|
|
3454
|
+
# 章节级+全局级 major,渲染进对应重写章的起草提示词;达标续跑为空表
|
|
3455
|
+
redo_issues = [x for x in (inherit.get("redo_issues") or []) if isinstance(x, dict)]
|
|
3415
3456
|
if inherit.get("outline"):
|
|
3416
3457
|
outline = inherit["outline"]
|
|
3417
3458
|
# 历史遗留:降级/模板大纲被继承时,真实任务宁可中止重生成,也不按空模板写全书
|
|
@@ -3840,6 +3881,26 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
3840
3881
|
if os.path.exists(os.path.join(workdir, ch_file)):
|
|
3841
3882
|
stale_note = ("- 注意:目录里已有一版**未通过评审**的旧稿 `%s`——不要校验沿用、"
|
|
3842
3883
|
"不要增量修补,按本章任务直接覆盖重写。" % ch_file)
|
|
3884
|
+
# 上一轮未达标原因(「重写未达标章」链路):本章被点名的 major 与
|
|
3885
|
+
# 全书级 major 随起草提示词下发——原因只躺在报告里、作者盲写等
|
|
3886
|
+
# 修订拉回,白白多烧一轮评审(2026-10-10 实案)。
|
|
3887
|
+
def _issue_ch(x):
|
|
3888
|
+
try:
|
|
3889
|
+
return int(x.get("chapter"))
|
|
3890
|
+
except (TypeError, ValueError):
|
|
3891
|
+
return None
|
|
3892
|
+
|
|
3893
|
+
redo_lines = []
|
|
3894
|
+
mine = [x for x in redo_issues if _issue_ch(x) == i]
|
|
3895
|
+
book_lvl = [x for x in redo_issues if _issue_ch(x) is None]
|
|
3896
|
+
if mine:
|
|
3897
|
+
redo_lines.append("- 本章被上一轮评审点名的问题(重写必须逐条消解):")
|
|
3898
|
+
redo_lines += _major_lines(mine)
|
|
3899
|
+
if book_lvl:
|
|
3900
|
+
redo_lines.append("- 上一轮全书级 major 问题(本章重写须配合消解,不得相抵):")
|
|
3901
|
+
redo_lines += _major_lines(book_lvl)
|
|
3902
|
+
redo_note = ("## 上一轮评审未达标原因(必读)\n" + "\n".join(redo_lines) + "\n"
|
|
3903
|
+
) if redo_lines else ""
|
|
3843
3904
|
# stable_order:同一任务 8 个章节的技能块必须字节级一致(§07 T1.2' 前缀缓存)
|
|
3844
3905
|
# 三块分开留底:起草重试的分层降级(_serial_shrunk_block)要按
|
|
3845
3906
|
# 「经验库/圣经」各自的身份收缩,折成一整块就只剩 4K 硬截一层了
|
|
@@ -3878,6 +3939,8 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
3878
3939
|
.replace("__HOOK__", branch_hook or ch.get("hook") or "留下悬念")
|
|
3879
3940
|
.replace("__LOCKED_CH__", serial_locked_chapter_block(workdir, i))
|
|
3880
3941
|
.replace("__STALE_NOTE__", stale_note)
|
|
3942
|
+
.replace("__REDO_ISSUES__", redo_note)
|
|
3943
|
+
.replace("__REVIEW_RUBRIC__", rubric_block)
|
|
3881
3944
|
.replace("__HIGHLIGHT__", ch.get("highlight") or "按剧情要点自然铺设一处小冲突/小反转")
|
|
3882
3945
|
.replace("__STYLE__", style_tpl)
|
|
3883
3946
|
.replace("__QUALITY_GATE__", novel_quality.opening_requirements(i, wpc))
|
|
@@ -4031,7 +4094,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
4031
4094
|
if not res["ok"]:
|
|
4032
4095
|
# 成品是文件不是退出码:CLI 超时但章稿已完整落盘(终章长文实测
|
|
4033
4096
|
# 反复出现——文件写完、收尾声明没等到)就送评审门把关,别整章作废
|
|
4034
|
-
live = (store.get_run(run_id).get("steps") or [])
|
|
4097
|
+
live = ((store.get_run(run_id) or {}).get("steps") or [])
|
|
4035
4098
|
if live:
|
|
4036
4099
|
store.finish_step(run_id, live[-1]["n"], "done",
|
|
4037
4100
|
summary="起草调用超时,但章稿已完整落盘(约 %d 字)——交评审门判质量"
|
|
@@ -4290,6 +4353,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
4290
4353
|
.replace("__VOLUME__", vol_block_for(i))
|
|
4291
4354
|
.replace("__BEATS__", ch.get("beats") or "按大纲推进")
|
|
4292
4355
|
.replace("__LOCKED_CH__", serial_locked_chapter_block(workdir, i))
|
|
4356
|
+
.replace("__REVIEW_RUBRIC__", rubric_block)
|
|
4293
4357
|
.replace("__HIGHLIGHT__", ch.get("highlight")
|
|
4294
4358
|
or "按剧情要点自然铺设一处小冲突/小反转")
|
|
4295
4359
|
.replace("__STYLE__", style_tpl)
|
|
@@ -4445,6 +4509,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
4445
4509
|
.replace("__VOLUME__", vol_block_for(j))
|
|
4446
4510
|
.replace("__BEATS__", _wch.get("beats") or "按大纲推进")
|
|
4447
4511
|
.replace("__LOCKED_CH__", serial_locked_chapter_block(workdir, j))
|
|
4512
|
+
.replace("__REVIEW_RUBRIC__", rubric_block)
|
|
4448
4513
|
.replace("__HIGHLIGHT__", _wch.get("highlight")
|
|
4449
4514
|
or "按剧情要点自然铺设一处小冲突/小反转")
|
|
4450
4515
|
.replace("__STYLE__", style_tpl)
|
|
@@ -4656,6 +4721,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
4656
4721
|
.replace("__VOLUME__", vol_block_for(i))
|
|
4657
4722
|
.replace("__BEATS__", ch.get("beats") or "按大纲推进")
|
|
4658
4723
|
.replace("__LOCKED_CH__", serial_locked_chapter_block(workdir, i))
|
|
4724
|
+
.replace("__REVIEW_RUBRIC__", rubric_block)
|
|
4659
4725
|
.replace("__HIGHLIGHT__", ch.get("highlight")
|
|
4660
4726
|
or "按剧情要点自然铺设一处小冲突/小反转")
|
|
4661
4727
|
.replace("__CRITIQUE__", crit)
|
|
@@ -5940,8 +6006,23 @@ def execute_run(run_id):
|
|
|
5940
6006
|
status="cancelled", ended_at=_now())
|
|
5941
6007
|
except Exception as e:
|
|
5942
6008
|
import traceback
|
|
6009
|
+
# 崩溃现场随错误行下发(2026-10-10 Mac 实案:连载连烧两轮自动续跑都
|
|
6010
|
+
# 死于 AttributeError("'NoneType' object has no attribute 'get'"),
|
|
6011
|
+
# 错误行只有一行 repr 无从定位;traceback 全文虽落 run 目录 error.log,
|
|
6012
|
+
# 但 UI 上看不见)。尾帧 = 文件:行号:函数名,一行说清死在哪;同因止损
|
|
6013
|
+
# 的错误签名因此按崩溃位置区分——同位置才算同因,代码升级后行号漂移
|
|
6014
|
+
# 自动能多试一轮(新代码可能已修复),次数上限仍兜底。
|
|
6015
|
+
_tb_tail = ""
|
|
6016
|
+
try:
|
|
6017
|
+
_frames = traceback.extract_tb(e.__traceback__)
|
|
6018
|
+
if _frames:
|
|
6019
|
+
_f = _frames[-1]
|
|
6020
|
+
_tb_tail = "(崩溃于 %s:%s %s)" % (
|
|
6021
|
+
os.path.basename(_f.filename), _f.lineno, _f.name)
|
|
6022
|
+
except Exception:
|
|
6023
|
+
pass
|
|
5943
6024
|
store.update_run(run_id, expected_status="running", status="failed",
|
|
5944
|
-
error=repr(e)[:500], ended_at=_now())
|
|
6025
|
+
error=(repr(e)[:380] + _tb_tail)[:500], ended_at=_now())
|
|
5945
6026
|
try:
|
|
5946
6027
|
err_path = store.run_dir(run_id) / "error.log"
|
|
5947
6028
|
if _inside(str(store.run_dir(run_id).parent), str(err_path)):
|
package/app/core/plugins.py
CHANGED
|
@@ -371,14 +371,21 @@ def install(plugin_id, roots=None):
|
|
|
371
371
|
"skill_count": len(old.get("skill_pack_ids") or [])}, None
|
|
372
372
|
source = Path(desc["dir"]).resolve()
|
|
373
373
|
target = (_install_root() / plugin_id).resolve()
|
|
374
|
+
market_id = "plugin-" + plugin_id
|
|
375
|
+
# 回滚标记必须在 try 之前就绑定:_copy_plugin/load_manifest 在记账前
|
|
376
|
+
# 抛错时 except 要靠它决定是否清 market 包,未初始化会把人话错误
|
|
377
|
+
# 炸成 UnboundLocalError→500,还会跳过残留目录清理让重装永远撞车。
|
|
378
|
+
market_written = False
|
|
374
379
|
try:
|
|
375
380
|
target.relative_to(_install_root().resolve())
|
|
381
|
+
if target.exists() and target != source:
|
|
382
|
+
# 有登记记录的已装插件在上面 already 分支就返回了;走到这里
|
|
383
|
+
# 还存在目标目录只可能是上次安装中途失败的残缺副本,清掉重装。
|
|
384
|
+
shutil.rmtree(target, ignore_errors=True)
|
|
376
385
|
_copy_plugin(source, target)
|
|
377
386
|
installed_desc = load_manifest(target)
|
|
378
387
|
files = _skill_files(installed_desc)
|
|
379
|
-
market_id = "plugin-" + plugin_id
|
|
380
388
|
skill_result = None
|
|
381
|
-
market_written = False
|
|
382
389
|
if files:
|
|
383
390
|
skill_result, err = market.install_files(
|
|
384
391
|
market_id, installed_desc["display_name"], files,
|
package/app/core/policy.py
CHANGED
|
@@ -50,9 +50,18 @@ def normalize_sandbox(spec=None, workdir=None):
|
|
|
50
50
|
else:
|
|
51
51
|
# Invalid policy input must never silently widen network access.
|
|
52
52
|
network = False
|
|
53
|
+
backend = str(spec.get("backend") or "native").strip().lower()
|
|
54
|
+
if backend not in ("native", "docker"):
|
|
55
|
+
raise ValueError("沙箱 backend 必须是 native 或 docker")
|
|
56
|
+
image = str(spec.get("docker_image") or "").strip()[:240]
|
|
57
|
+
if backend == "docker":
|
|
58
|
+
import re
|
|
59
|
+
if not image or not re.match(r"^[A-Za-z0-9][A-Za-z0-9._/:@-]*$", image):
|
|
60
|
+
raise ValueError("Docker 镜像名无效")
|
|
53
61
|
return {"allowed_roots": [str(x) for x in roots], "env_allowlist": env,
|
|
54
62
|
"network": network, "timeout_s": timeout,
|
|
55
63
|
"max_output_bytes": output,
|
|
64
|
+
"backend": backend, "docker_image": image,
|
|
56
65
|
"disabled_tools": normalize_disabled_tools(spec.get("disabled_tools"))}
|
|
57
66
|
|
|
58
67
|
|
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
|