codebee 0.1.17 → 0.1.19

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.
@@ -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