codebee 0.1.18 → 0.1.20

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.
@@ -163,8 +163,26 @@ def _pipe_reader(stream, chunks, log_fh, stamp=None):
163
163
  pass
164
164
 
165
165
 
166
+ def _decode_line(blob):
167
+ """单行解码:UTF-8 → GBK → replace(与 decode_output 同一编码纪律)。"""
168
+ try:
169
+ return blob.decode("utf-8")
170
+ except UnicodeDecodeError:
171
+ pass
172
+ try:
173
+ return blob.decode("gbk")
174
+ except UnicodeDecodeError:
175
+ return blob.decode("utf-8", "replace")
176
+
177
+
166
178
  def decode_output(data):
167
- """子进程输出解码:UTF-8 严格解码失败时回退 GBK(中文 Windows 控制台)。"""
179
+ """子进程输出解码:UTF-8 严格解码失败时回退 GBK(中文 Windows 控制台)。
180
+
181
+ 整块 UTF-8 与整块 GBK 都失败时逐行兜底——日志文件是混合编码(我方 UTF-8
182
+ 审计头 + CLI 自家 GBK 输出),整块回退会把能读的部分一起牺牲:要么头部中文
183
+ 变乱码,要么整篇落进 replace 分支铺成 U+FFFD 墙(2026-09-20 aider 日志实证,
184
+ 详情页卡片显示成一片 ◆)。逐行解码两边都保住(GBK/UTF-8 的多字节序列都不
185
+ 含 0x0A,按行切不会切坏字符)。"""
168
186
  if not data:
169
187
  return ""
170
188
  try:
@@ -174,7 +192,8 @@ def decode_output(data):
174
192
  try:
175
193
  return data.decode("gbk")
176
194
  except UnicodeDecodeError:
177
- return data.decode("utf-8", "replace")
195
+ pass
196
+ return "\n".join(_decode_line(ln) for ln in data.split(b"\n"))
178
197
 
179
198
 
180
199
  def read_text_any_enc(path):
@@ -201,6 +220,64 @@ def tail_decoded(data, tail):
201
220
  return decode_output(chunk[i:])
202
221
 
203
222
 
223
+ # 终端控制序列:CSI(颜色/光标/清行,`\x1b[91m`)、OSC(窗口标题)、其余两字符转义
224
+ _ANSI_RE = re.compile(
225
+ r"\x1b\[[0-9;?]*[ -/]*[@-~]"
226
+ r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?"
227
+ r"|\x1b[@-Z\\-_]"
228
+ )
229
+ # 被切片/前端截断掉 ESC 的「裸序列」残尾(`[91m`、`[0m`):只在行首止血,
230
+ # 正文里合法的 [数字+字母](如 [1m] 引用)不动——后面紧跟 `]` 的不算残尾
231
+ _ANSI_HEAD_RE = re.compile(r"^\[[0-9;?]{1,6}[A-Za-z](?!\])")
232
+ # CLI 拿来做进度条/画框/旋转动画的图元,成串出现时人读不出任何信息
233
+ _NOISE_RUN_RE = re.compile(r"([\u2500-\u259f\u25a0-\u25ff\u2800-\u28ff])\1{7,}")
234
+ # 解不出来的字节(replace 兜底)成串即「乱码墙」——多数等宽字体把 U+FFFD 画成
235
+ # 带问号的菱形,一屏看起来就是 ◆◆◆◆
236
+ _FFFD_RUN_RE = re.compile(r"\ufffd{3,}")
237
+ _CTRL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
238
+
239
+
240
+ def strip_ansi(text):
241
+ """剥掉 ANSI 转义序列(颜色/光标/进度),并清掉行首被截断的裸序列残尾。"""
242
+ if not text:
243
+ return ""
244
+ out = _ANSI_RE.sub("", text)
245
+ # 残尾可能连着几个(`[91m[0mError:`),一次替换只够消掉最前面那个
246
+ while True:
247
+ nxt = "\n".join(_ANSI_HEAD_RE.sub("", ln) for ln in out.split("\n"))
248
+ if nxt == out:
249
+ return out
250
+ out = nxt
251
+
252
+
253
+ def clean_cli_text(text):
254
+ """CLI 原始输出 → 人类可读文本(只用于错误摘要/日志展示,不碰模型正文)。
255
+
256
+ 终端噪声四种,都是实测踩过的:
257
+ 1. ANSI 转义:`\\x1b[91m\\x1b[1mError:` 在浏览器里渲染成 `[91m[1mError:`;
258
+ 2. 裸 `\\r` 覆写:进度条/旋转动画反复回退改写同一行,原样展示会叠成一片;
259
+ `\\r\\n` 是行结束符不是覆写,先归一化再按覆写取「该行最终形态」;
260
+ 3. 画线/进度图元成串(`────…`、`████…`)与 U+FFFD 乱码墙(编码不可解);
261
+ 4. 剩余控制字符。
262
+ """
263
+ if not text:
264
+ return ""
265
+ out = strip_ansi(text).replace("\r\n", "\n")
266
+ if "\r" in out:
267
+ lines = []
268
+ for ln in out.split("\n"):
269
+ if "\r" in ln:
270
+ parts = ln.split("\r")
271
+ ln = next((p for p in reversed(parts) if p.strip()), parts[-1])
272
+ lines.append(ln)
273
+ out = "\n".join(lines)
274
+ out = _CTRL_RE.sub("", out)
275
+ out = _FFFD_RUN_RE.sub(
276
+ lambda m: "…(%d 个字符无法解码:CLI 输出不是 UTF-8/GBK)" % len(m.group(0)), out)
277
+ out = _NOISE_RUN_RE.sub(lambda m: m.group(1) * 3 + "…", out)
278
+ return re.sub(r"\n{4,}", "\n\n\n", out)
279
+
280
+
204
281
  _TS_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}T[\d:.,]+Z?\s*")
205
282
 
206
283
 
@@ -600,7 +677,11 @@ _TRANSIENT = ("503", "502", "529", "429", "no available channel", "temporarily",
600
677
  # 「非瞬态不降级」,导致跨厂商链上健康的后继模型从未被尝试。
601
678
  "unknown model", "1211", "connection error", "econnrefused",
602
679
  "connection aborted", "initialize", "reset by peer",
603
- "channel is closed", "no route to host")
680
+ "channel is closed", "no route to host",
681
+ # 2026-09-20 连载评审实测:codex 网关断流(stream disconnected)
682
+ # 与 opencode 服务端 500(Unexpected server error)都是「重试/换将
683
+ # 就可能活」的瞬态病,旧表判成终态导致整链早死
684
+ "stream disconnected", "unexpected server error")
604
685
 
605
686
 
606
687
  def _transient_error(err):
@@ -809,8 +890,21 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=
809
890
  stdin_text = prompt
810
891
  elif kind == "aider":
811
892
  argv = resolve_command(agent["command"]) + [
812
- "--yes-always", "--no-auto-commits", "--no-check-update", "--message", prompt]
893
+ "--yes-always", "--no-auto-commits", "--no-check-update",
894
+ # 网关自定义模型名(glm-5.1 等)litellm 全都不认识,警告页+建议列表
895
+ # 纯属刷屏(2026-09-20 实测占满步骤日志头部)
896
+ "--no-show-model-warnings", "--message", prompt]
813
897
  if model:
898
+ # litellm 靠 provider 前缀路由,裸模型名直接报
899
+ # "LLM Provider NOT provided"。前缀按本条尝试实际注入的端点协议定:
900
+ # ANTHROPIC_BASE_URL=anthropic 面(Bigmodel 等)、OPENAI_API_BASE=
901
+ # openai 兼容面;都没有=CLI 本机默认场景,模型名保持用户原样
902
+ envd = agent.get("env") or {}
903
+ if "/" not in model:
904
+ if envd.get("ANTHROPIC_BASE_URL"):
905
+ model = "anthropic/" + model
906
+ elif envd.get("OPENAI_API_BASE"):
907
+ model = "openai/" + model
814
908
  argv += ["--model", model]
815
909
  else: # generic:模板把 {prompt}/{session} 嵌进参数(注意 cmd 行长度限制)
816
910
  tmpl = agent.get("argv_template") or ["-p", "{prompt}"]
@@ -968,7 +1062,9 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
968
1062
  # stderr 与 stdout 都要进错误串:codex 把 "Reading prompt from
969
1063
  # stdin..." 打在 stderr,真正的配额/限流错误全在 stdout 的 JSONL
970
1064
  # 里——只取其一会让 _quota_error/_transient_error 判空。
971
- tail = ((res["stderr"] or "") + "\n" + (res["stdout"] or "")).strip()[-600:]
1065
+ # 先洗后切:切片会割断 ANSI 序列,留下 `[91m` 这种裸残尾直接进 UI
1066
+ tail = clean_cli_text(
1067
+ ((res["stderr"] or "") + "\n" + (res["stdout"] or ""))[-4000:]).strip()[-600:]
972
1068
  head = ("输出停滞 %ss(stall timed out,疑似卡死已提前终止)" % stall_t
973
1069
  if res.get("stalled")
974
1070
  else "超时" if res["timed_out"]
@@ -1025,7 +1121,7 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1025
1121
  out["error"] = "codex: %s" % fm
1026
1122
  out["error_code"] = ErrorCode.VENDOR_ERROR
1027
1123
  elif not out["text"]: # 事件流解析失败时退化为取 stdout 尾部
1028
- out["text"] = res["stdout"][-2000:]
1124
+ out["text"] = clean_cli_text(res["stdout"][-2000:])
1029
1125
  if require_tools and out["ok"] and _codex_work_events(res["stdout"]) == 0:
1030
1126
  # 实现步空转闸:exit 0 + 有话但零动手 → 判拒绝(fatal 语义正确,
1031
1127
  # 不把谎报的「已改完」静默传给下游;auto 模式换将逻辑按 ok 触发,
@@ -1060,7 +1156,10 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1060
1156
  out["error_code"] = _classify_failure(
1061
1157
  res, kind="claude", parsed=parsed, attempt_done=True)
1062
1158
  else:
1063
- out["text"] = res["stdout"].strip()
1159
+ # generic(aider/opencode/kimi…)的 stdout 就是终端转录:ANSI 颜色、
1160
+ # \r 进度条、画线框全在里面。不清洗的话它同时污染两处——步骤摘要
1161
+ # (详情页卡片显示成 ◆/─ 墙)与评审 JSON 解析(转义混进正文)
1162
+ out["text"] = clean_cli_text(res["stdout"]).strip()
1064
1163
  if not out["text"]:
1065
1164
  out["error_code"] = _classify_failure(res, kind=kind, empty_output=True)
1066
1165
  break
@@ -1162,6 +1261,27 @@ def as_scores(gj):
1162
1261
  return gj if isinstance(gj, dict) else None
1163
1262
 
1164
1263
 
1264
+ def extract_scores_from_text(text):
1265
+ """评审解析第四道网(借鉴 BAML 的宽容提取):模型把分数写成散文键值对
1266
+ 完全不出 JSON 时(2026-09-18 真实案例:kimi 正文提分),从文本直接抓
1267
+ 「维度:N 分」模式。返回 scores dict 或 {}。"""
1268
+ if not text:
1269
+ return {}
1270
+ scores = {}
1271
+ for m in re.finditer(
1272
+ r"[\u4e00-\u9fa5A-Za-z][\u4e00-\u9fa5A-Za-z0-9 ]{0,11}"
1273
+ r"[::]\s*(\d{1,2}(?:\.\d)?)\s*分?", text):
1274
+ dim = m.group(0).rsplit(":", 1)[0].rsplit(":", 1)[0].strip()
1275
+ try:
1276
+ val = float(m.group(1))
1277
+ except ValueError:
1278
+ continue
1279
+ if not dim or dim in scores or not (1.0 <= val <= 10.0):
1280
+ continue
1281
+ scores[dim] = round(val, 1)
1282
+ return scores if len(scores) >= 3 else {}
1283
+
1284
+
1165
1285
  def scores_from_prose(text, dims):
1166
1286
  """评审解析第三道网:agentic CLI 有时把 JSON 写进文件、stdout 只留中文
1167
1287
  总结(「情节 8 / 人物 8 / …」)。JSON 全灭后按维度名从正文提分;
@@ -18,9 +18,9 @@ SSE 可看进度)。npm 替换的是包目录文件,当前进程已加载进
18
18
  """
19
19
  from __future__ import annotations
20
20
 
21
- import json
22
- import logging
23
- import os
21
+ import json
22
+ import logging
23
+ import os
24
24
  import re
25
25
  import socket
26
26
  import subprocess
@@ -29,11 +29,11 @@ import threading
29
29
  import time
30
30
  from pathlib import Path
31
31
 
32
- from . import paths, runner
33
-
34
- log = logging.getLogger(__name__)
35
-
36
- _PKG_NAME = "codebee" # npm 发布名;必须与 package.json 的 name 一致(单测断言)
32
+ from . import paths, runner
33
+
34
+ log = logging.getLogger(__name__)
35
+
36
+ _PKG_NAME = "codebee" # npm 发布名;必须与 package.json 的 name 一致(单测断言)
37
37
  _UPDATE_TTL = 600 # 查新结果缓存(秒)
38
38
  _LOCK = threading.Lock()
39
39
  _CHECK_CACHE = {"ts": 0.0, "result": None}
@@ -161,46 +161,88 @@ def check(force=False):
161
161
  return out
162
162
 
163
163
 
164
- def apply_upgrade():
164
+ def apply_upgrade():
165
165
  """发起升级:建 mgmt run 异步跑 npm install -g @latest。返回 {run_id} 或 {error}。"""
166
166
  if install_mode() != "npm":
167
167
  return {"error": "当前安装方式不支持自动升级(见版本页说明)"}
168
168
  from . import store, jobs
169
- try:
170
- run = store.create_run(
171
- "mgmt", "升级 CodeBee 本体(npm install -g %s@latest)" % _PKG_NAME,
172
- entry_id="__self__", op="selfupgrade")
173
- except Exception:
174
- log.exception("selfupdate: 创建升级运行记录失败")
175
- return {"error": "升级任务创建失败,请稍后重试"}
176
- try:
177
- jobs.enqueue({"kind": "selfupgrade", "run_id": run["id"]})
178
- except Exception:
179
- # The run is already durable when enqueue fails. Close it explicitly so
180
- # the upgrade panel cannot remain in a misleading queued state.
181
- log.exception("selfupdate: 升级任务入队失败 run=%s", run["id"])
182
- try:
183
- store.update_run(run["id"], status="failed",
184
- error="升级任务入队失败,请稍后重试",
185
- ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
186
- except Exception:
187
- log.exception("selfupdate: 升级运行失败收口失败 run=%s", run["id"])
188
- return {"error": "升级任务入队失败,请稍后重试", "run_id": run["id"]}
189
- return {"run_id": run["id"]}
169
+ try:
170
+ run = store.create_run(
171
+ "mgmt", "升级 CodeBee 本体(npm install -g %s@latest)" % _PKG_NAME,
172
+ entry_id="__self__", op="selfupgrade")
173
+ except Exception:
174
+ log.exception("selfupdate: 创建升级运行记录失败")
175
+ return {"error": "升级任务创建失败,请稍后重试"}
176
+ try:
177
+ jobs.enqueue({"kind": "selfupgrade", "run_id": run["id"]})
178
+ except Exception:
179
+ # The run is already durable when enqueue fails. Close it explicitly so
180
+ # the upgrade panel cannot remain in a misleading queued state.
181
+ log.exception("selfupdate: 升级任务入队失败 run=%s", run["id"])
182
+ try:
183
+ store.update_run(run["id"], status="failed",
184
+ error="升级任务入队失败,请稍后重试",
185
+ ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
186
+ except Exception:
187
+ log.exception("selfupdate: 升级运行失败收口失败 run=%s", run["id"])
188
+ return {"error": "升级任务入队失败,请稍后重试", "run_id": run["id"]}
189
+ return {"run_id": run["id"]}
190
+
191
+
192
+ _LOCKED_RE = re.compile(r"\b(EBUSY|EPERM)\b")
193
+ _RETRY_DELAYS = (5, 15) # 目录被占用时自动重试前的等待秒数(暂时性占用多在此窗口内释放)
194
+
195
+
196
+ def _locked_error(res):
197
+ """npm 失败输出是否为「包目录被占用」类(EBUSY/EPERM)——值得等一等重试。"""
198
+ blob = ((res or {}).get("stderr") or "") + ((res or {}).get("stdout") or "")
199
+ return bool(_LOCKED_RE.search(blob[-4000:]))
200
+
201
+
202
+ def _log_note(log_path, text):
203
+ """向步骤日志追加一行进度说明(run_process 以 append 模式写同一文件)。"""
204
+ if not log_path:
205
+ return
206
+ try:
207
+ with open(log_path, "ab") as fh:
208
+ fh.write(("\n===== %s =====\n" % text).encode("utf-8", "replace"))
209
+ except Exception:
210
+ pass
190
211
 
191
212
 
192
213
  def run_upgrade(run_id, log_path):
193
- """worker 线程里执行升级命令(run/step 生命周期由 jobs 层管)。"""
194
- res = runner.run_process(
195
- argv=_npm_argv("install", "-g", _PKG_NAME + "@latest"),
196
- # Windows 上 npm 换版本靠把包目录整体改名(codebee → .codebee-xxx);
197
- # cwd 若落在本包内,目录被自身进程占用,rename 必报 EBUSY——钉在包外
198
- cwd=str(Path.home()), timeout=900, log_path=log_path)
214
+ """worker 线程里执行升级命令(run/step 生命周期由 jobs 层管)。
215
+
216
+ 包目录被其他进程占用(EBUSY/EPERM:打开包目录的资源管理器/终端窗口、
217
+ 杀毒或索引扫描)是升级失败的最常见原因,且多为暂时性——自动重试
218
+ _RETRY_DELAYS 轮,仍败则给人话结论(原始 npm 输出在步骤日志里可查)。"""
219
+ res = {}
220
+ for attempt, delay in enumerate((0,) + _RETRY_DELAYS):
221
+ if delay:
222
+ _log_note(log_path, "目录被占用(EBUSY/EPERM),%d 秒后自动重试(第 %d/%d 次)"
223
+ % (delay, attempt, len(_RETRY_DELAYS)))
224
+ time.sleep(delay)
225
+ res = runner.run_process(
226
+ argv=_npm_argv("install", "-g", _PKG_NAME + "@latest"),
227
+ # Windows 上 npm 换版本靠把包目录整体改名(codebee → .codebee-xxx);
228
+ # cwd 若落在本包内,目录被自身进程占用,rename 必报 EBUSY——钉在包外
229
+ cwd=str(Path.home()), timeout=900, log_path=log_path)
230
+ if res["ok"] or not _locked_error(res):
231
+ break
199
232
  if res["ok"]:
200
233
  with _LOCK: # 装完即过期查新缓存,重启后自然拿到新版本
201
234
  _CHECK_CACHE["result"] = None
202
- return {"ok": res["ok"], "exit_code": res["exit_code"],
203
- "error": "" if res["ok"] else (res["stderr"][-800:] or "退出码 %s" % res["exit_code"])}
235
+ return {"ok": True, "exit_code": res["exit_code"], "error": ""}
236
+ stderr = res["stderr"] or ""
237
+ if _locked_error(res):
238
+ err = ("升级失败:codebee 安装目录被其他程序占用(已自动重试 %d 次未恢复)。"
239
+ "常见占用:打开包目录的资源管理器窗口/终端、杀毒或索引扫描。"
240
+ "请关闭相关窗口后回版本页重试;仍不行可退出 CodeBee 后手动执行 "
241
+ "npm install -g %s@latest。" % (len(_RETRY_DELAYS), _PKG_NAME))
242
+ else:
243
+ # npm 的进度条/颜色转义与中文 Windows 的 GBK 输出都进过这里,先洗再用
244
+ err = runner.clean_cli_text(stderr)[-800:] or "退出码 %s" % res["exit_code"]
245
+ return {"ok": False, "exit_code": res["exit_code"], "error": err}
204
246
 
205
247
 
206
248
  def _port_free(port):
@@ -16,9 +16,19 @@ _FILE = paths.DATA_DIR / "settings.json"
16
16
  # 「导出诊断包」是用户手动操作不受此开关限制)
17
17
  # publish_daily_cap / publish_fail_streak:自动发布护栏——每任务每平台每日
18
18
  # 成功发章上限、平台连续失败几次后暂停自动发布(publish/auto.py 读取)
19
+ # pet_enabled / pet_mode:桌面蜜蜂(app/pet.py)开关与显示模式。关闭后看护线程
20
+ # 不再拉起、在岗蜜蜂轮询到 false 自行退出;mode=always 常驻,tasks_only 仅任务
21
+ # 运行时出现(空闲 90s 隐身)。
22
+ # cleanup_enabled / cleanup_retention_days:每日垃圾清理(core/cleanup.py)——
23
+ # 运行过程日志/发布截图/bak 残留等超期自动清理;retention 为保留天数。
19
24
  DEFAULTS = {"max_concurrent_jobs": 6, "default_workdir": "", "hooks_token": "",
20
25
  "telemetry_errors": True, "publish_daily_cap": 10,
21
- "publish_fail_streak": 3, "notify_webhook": "", "notify_base_url": ""}
26
+ "publish_fail_streak": 3, "notify_webhook": "", "notify_base_url": "",
27
+ "pet_enabled": True, "pet_mode": "always", "pet_skin": "plush",
28
+ "cleanup_enabled": True, "cleanup_retention_days": 14}
29
+ # 桌宠形象白名单(与 app/pet.py 的 SKINS 对齐;这里不 import pet 模块,避免
30
+ # core 反向依赖 app 根目录脚本)
31
+ PET_SKINS = ("plush", "robot")
22
32
  # 并发上限 12:worker 只是拉起 CLI 子进程的调度位,跨任务无共享资源;
23
33
  # 同任务单飞守卫在 jobs 层。默认 6 对齐「多任务并行不排队」的使用预期。
24
34
  MIN_WORKERS, MAX_WORKERS = 1, 12
@@ -103,6 +113,27 @@ def save(patch):
103
113
  cur["notify_webhook"] = str(patch.get("notify_webhook") or "").strip()[:300]
104
114
  if "notify_base_url" in patch:
105
115
  cur["notify_base_url"] = str(patch.get("notify_base_url") or "").strip()[:200]
116
+ if "pet_enabled" in patch:
117
+ cur["pet_enabled"] = bool(patch.get("pet_enabled"))
118
+ if "pet_mode" in patch:
119
+ pm = str(patch.get("pet_mode") or "").strip()
120
+ if pm not in ("always", "tasks_only"):
121
+ return cur, "pet_mode 只能是 always 或 tasks_only"
122
+ cur["pet_mode"] = pm
123
+ if "pet_skin" in patch:
124
+ ps = str(patch.get("pet_skin") or "").strip()
125
+ if ps not in PET_SKINS:
126
+ return cur, "pet_skin 只能是 %s 之一" % "/".join(PET_SKINS)
127
+ cur["pet_skin"] = ps
128
+ if "cleanup_enabled" in patch:
129
+ cur["cleanup_enabled"] = bool(patch.get("cleanup_enabled"))
130
+ if "cleanup_retention_days" in patch:
131
+ try:
132
+ cur["cleanup_retention_days"] = int(patch.get("cleanup_retention_days"))
133
+ except (TypeError, ValueError):
134
+ return cur, "cleanup_retention_days 必须是整数"
135
+ if not 1 <= cur["cleanup_retention_days"] <= 365:
136
+ return cur, "cleanup_retention_days 取值 1-365"
106
137
  _FILE.parent.mkdir(parents=True, exist_ok=True)
107
138
  tmp = _FILE.with_suffix(".tmp")
108
139
  tmp.write_text(json.dumps(cur, ensure_ascii=False, indent=2), encoding="utf-8")
@@ -0,0 +1,86 @@
1
+ # -*- coding: utf-8 -*-
2
+ """skill 装前危险模式扫描(借鉴 NVIDIA SkillSpector 的静态扫描思路)。
3
+
4
+ 研究数据(SkillSpector 对 31,132 个 skill 的分析):26.1% 含漏洞、5.2% 疑似
5
+ 恶意。我们在「白名单闸门 + SSRF 防护 + 剥离式检查」之外补一道**内容静态扫描**:
6
+ 装前扫 skill 正文里的危险模式,给风险提示——提示不是拦阻(用户仍可装),
7
+ 但危险必须被看见。
8
+
9
+ 纯内存函数:输入是文本,输出是发现列表,无网络无落盘。
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ # 危险模式(静态正则;命中即在装前提示行展示)
16
+ # —— 每条:(模式, 类别, 中文说明)
17
+ _PATTERNS = [
18
+ # 代码执行
19
+ (r"\beval\s*\(", "代码执行", "动态 eval 执行任意代码"),
20
+ (r"\bexec\s*\(", "代码执行", "exec 执行任意代码"),
21
+ (r"subprocess|os\.system|Popen", "代码执行", "起子进程执行命令"),
22
+ # 数据外发
23
+ (r"https?://(?!api\.|docs\.|github\.com|raw\.githubusercontent)[a-z0-9.-]+/(upload|collect|track|ingest|webhook|callback)",
24
+ "数据外发", "向非常规端点上传/回调数据"),
25
+ (r"requests\.post|urllib\.request|fetch\s*\(", "网络请求", "发起网络请求(确认目标可信)"),
26
+ # 敏感信息读取
27
+ (r"os\.environ|process\.env|getenv", "环境读取", "读取环境变量(可能带走密钥)"),
28
+ (r"\.ssh/|\.aws/|\.npmrc|\.gitconfig|credentials|\.env\b", "敏感文件", "触碰凭据/密钥文件路径"),
29
+ (r"keychain|credential manager|dpapi", "敏感文件", "访问系统凭据库"),
30
+ # 提示注入特征
31
+ (r"(ignore|disregard|forget).{0,30}(previous|above|prior|all).{0,20}(instruction|prompt|rule)",
32
+ "提示注入", "指令覆盖话术(试图无视既有规则)"),
33
+ (r"system prompt|开发者指令|隐藏指令", "提示注入", "提及系统提示词/隐藏指令"),
34
+ (r"do not (tell|reveal|mention).{0,20}(user|human|player)", "提示注入", "要求对用户隐瞒行为"),
35
+ # 反拒答/越权
36
+ (r"(you (are|must) (now|act as)|从此你是|你现在必须)", "越权人格", "试图重设助手人格"),
37
+ (r"exfiltrat|渗透|后门|backdoor|keylog", "可疑意图", "包含可疑渗透/后门词汇"),
38
+ ]
39
+
40
+ _COMPILED = [(re.compile(p, re.I), cat, desc) for p, cat, desc in _PATTERNS]
41
+
42
+
43
+ def scan_text(text, max_findings=12):
44
+ """扫描一段 skill 文本。返回 [{category, detail, line}];空列表=干净。
45
+
46
+ line 是 1 起的行号(供装前提示定位)。同一类别只报首个命中
47
+ (提示行是给人看的,重复刷屏没有信息量)。"""
48
+ findings, seen_cats = [], set()
49
+ if not text:
50
+ return []
51
+ lines = text.splitlines()
52
+ for rx, cat, desc in _COMPILED:
53
+ if cat in seen_cats:
54
+ continue
55
+ for i, ln in enumerate(lines, 1):
56
+ if rx.search(ln):
57
+ findings.append({"category": cat, "detail": desc, "line": i})
58
+ seen_cats.add(cat)
59
+ break
60
+ if len(findings) >= max_findings:
61
+ break
62
+ return findings
63
+
64
+
65
+ def risk_label(findings):
66
+ """发现列表 → 风险标签(装前提示行用)。
67
+
68
+ 高危(代码执行/敏感文件/可疑意图)任一命中 = 高风险;否则有发现 = 注意;
69
+ 空 = 干净。"""
70
+ if not findings:
71
+ return ""
72
+ cats = {f["category"] for f in findings}
73
+ if cats & {"代码执行", "敏感文件", "可疑意图", "数据外发"}:
74
+ return "⚠ 高风险"
75
+ return "△ 注意"
76
+
77
+
78
+ def scan_summary(text):
79
+ """一步到位:扫描+汇总成一行提示文案(空=干净返回空串)。"""
80
+ fs = scan_text(text)
81
+ if not fs:
82
+ return ""
83
+ label = risk_label(fs)
84
+ top = "、".join("%s(行%d)" % (f["category"], f["line"]) for f in fs[:4])
85
+ more = " 等 %d 项" % len(fs) if len(fs) > 4 else ""
86
+ return "%s:%s%s" % (label, top, more)
package/app/core/store.py CHANGED
@@ -410,6 +410,28 @@ def migrate_task_workdirs(old_root, new_root):
410
410
  return moved, skipped
411
411
 
412
412
 
413
+ def _sanitize_run_text(r):
414
+ """就地清洗 run 与各步骤里的 CLI 文本字段(错误串/摘要/输出)。
415
+
416
+ 写入侧与读盘侧共用:runner 已经把 ANSI/覆写/乱码墙洗过一遍,这里兜住
417
+ 「错误串是我们自己拼的」「历史数据是旧版本写的」两条漏网路径。只动字符串
418
+ 值,不碰结构——清洗是幂等的,重复调用无副作用。"""
419
+ for k in ("error", "summary"):
420
+ v = r.get(k)
421
+ if isinstance(v, str) and v:
422
+ r[k] = runner.clean_cli_text(v)
423
+ for s in r.get("steps") or []:
424
+ if not isinstance(s, dict):
425
+ continue
426
+ for k in ("summary", "error"):
427
+ v = s.get(k)
428
+ if isinstance(v, str) and v:
429
+ s[k] = runner.clean_cli_text(v)
430
+ # output 是智能体正文:只剥 ANSI(同 finish_step 的口径)
431
+ if isinstance(s.get("output"), str) and s["output"]:
432
+ s["output"] = runner.strip_ansi(s["output"])
433
+
434
+
413
435
  def load_all():
414
436
  with LOCK:
415
437
  for p in paths.TASKS_DIR.glob("*.json"):
@@ -422,6 +444,10 @@ def load_all():
422
444
  try:
423
445
  r = json.loads(p.read_text(encoding="utf-8"))
424
446
  r.pop("cancel_event", None)
447
+ # 存量清洗:早期版本把 CLI 的 ANSI 转义/控制字符原样写进了摘要与
448
+ # 错误串(`[91m[1mError:`、U+FFFD 乱码墙),读盘时统一洗一遍——
449
+ # 老运行不必等重跑才干净(2026-09-20「咋还有乱码」实测)
450
+ _sanitize_run_text(r)
425
451
  # 队列不跨进程持久化:磁盘上仍是 queued/running 的运行必是上次进程中断的残骸
426
452
  if r.get("status") in ("queued", "running"):
427
453
  r["status"] = "failed"
@@ -620,6 +646,11 @@ def update_run(run_id, expected_status=None, **fields):
620
646
  return None
621
647
  if expected_status is not None and run.get("status") != expected_status:
622
648
  return None
649
+ for k in ("error", "summary"):
650
+ if isinstance(fields.get(k), str):
651
+ # 错误串多是我们自己拼的 CLI 尾巴(含 ANSI/覆写/乱码墙):
652
+ # 落内存前洗一遍,UI/台账读到的就是干净文本
653
+ fields[k] = runner.clean_cli_text(fields[k])
623
654
  run.update(fields)
624
655
  # 终态收尸:run 已结束却还挂 queued/running 的步骤统一落 cancelled,
625
656
  # 语义与 recover_orphaned_runs 的启动清扫对齐(那套清跨进程遗留,
@@ -1105,7 +1136,26 @@ def retry_task(task_id):
1105
1136
  if s.get("status") == "done"
1106
1137
  and (s.get("role") or "").startswith("draft-c")
1107
1138
  and str(s.get("role")).split("c")[-1].isdigit()})
1108
- if done:
1139
+ v_scores = (prev.get("verdict") or {}).get("chapter_scores") or []
1140
+ # 重写未达标章:上一遍完整跑完但质量未过线(verdict.publishable
1141
+ # =False)时,未过线章的成稿与分数都不进继承——流水线对缺继承
1142
+ # 的章走正常起草+评审,等于只重写这几章;已过线章照常复用不烧
1143
+ # token。大纲始终继承(全章未过线时 done 清空也继承),保住全书
1144
+ # 结构——这正是「重写未达标章」按钮(前端 done+未达标态放行
1145
+ # retry)区别于断点续跑的语义。
1146
+ redo = set()
1147
+ if (prev.get("verdict") or {}).get("publishable") is False:
1148
+ redo = {int(c["chapter"]) for c in v_scores
1149
+ if not c.get("passed") and c.get("chapter") is not None}
1150
+ if redo:
1151
+ done = [n for n in done if n not in redo]
1152
+ v_scores = [c for c in v_scores if c.get("passed")]
1153
+ if done or redo:
1154
+ if redo:
1155
+ scores = v_scores # 未达标重写:只带已过线章的分数
1156
+ else:
1157
+ scores = ((prev.get("verdict") or {}).get("chapter_scores")
1158
+ or prev.get("chapter_scores") or [])
1109
1159
  run["inherit"] = {
1110
1160
  "outline": outline,
1111
1161
  "done_chapters": done,
@@ -1113,8 +1163,7 @@ def retry_task(task_id):
1113
1163
  # failed/cancelled 的 run 没有 verdict,退回每章实时
1114
1164
  # 落账的 chapter_scores——否则多轮失败恢复会把全部
1115
1165
  # 已过线章节重新评审(实测一晚白烧数百万 token)
1116
- "chapter_scores": ((prev.get("verdict") or {}).get("chapter_scores")
1117
- or prev.get("chapter_scores") or []),
1166
+ "chapter_scores": scores,
1118
1167
  }
1119
1168
  break
1120
1169
  task["status"] = "queued"
@@ -1333,7 +1382,9 @@ def finish_step(run_id, n, status, summary="", exit_code=None,
1333
1382
  if s["n"] == n:
1334
1383
  s["status"] = status
1335
1384
  s["ended_at"] = time.strftime("%H:%M:%S")
1336
- s["summary"] = summary
1385
+ # 步骤摘要是 CLI 文本的汇聚点(失败时=错误尾巴,成功时=智能体结论):
1386
+ # 落盘前统一清洗,避免 ANSI/覆写/乱码墙进 UI 与报告
1387
+ s["summary"] = runner.clean_cli_text(summary)
1337
1388
  s["exit_code"] = exit_code
1338
1389
  s["cost_usd"] = round(cost_usd, 4)
1339
1390
  s["tokens"] = tokens
@@ -1342,7 +1393,9 @@ def finish_step(run_id, n, status, summary="", exit_code=None,
1342
1393
  if duration_s is not None:
1343
1394
  s["duration_s"] = round(duration_s, 1)
1344
1395
  if output is not None:
1345
- s["output"] = str(output)[:6000]
1396
+ # output 是智能体正文(对话气泡直读):只剥 ANSI,不做噪声折叠
1397
+ # ——正文里的装饰性长串是作者写的,不能替它省略
1398
+ s["output"] = runner.strip_ansi(str(output))[:6000]
1346
1399
  if followups:
1347
1400
  s["followups"] = list(followups)[:3]
1348
1401
  break
@@ -1497,9 +1550,10 @@ def read_step_log(run_id, rel_path, tail=paths.LOG_TAIL_CHARS, pretty=False):
1497
1550
  text = "...(已截断)...\n" + runner.tail_decoded(data, tail)
1498
1551
  else:
1499
1552
  text = runner.decode_output(data)
1500
- # 折叠遥测刷屏(时间戳不同的重复 WARN);pretty 再把 codex JSONL
1501
- # 事件流翻译成【消息】【命令】等可读行,日志抽屉直读"蜂在干什么"
1502
- text = runner.collapse_dup_lines(text)
1553
+ # 先洗终端噪声(ANSI/覆写/乱码墙),再折叠遥测刷屏(时间戳不同的重复
1554
+ # WARN);pretty 最后把 codex JSONL 事件流翻译成【消息】【命令】等可读行
1555
+ # ——顺序不能反:乱码墙会挤掉折叠分组,翻译也会把 ANSI 当正文
1556
+ text = runner.collapse_dup_lines(runner.clean_cli_text(text))
1503
1557
  if pretty:
1504
1558
  text = runner.pretty_cli_log(text)
1505
1559
  return text