codebee 0.1.55 → 0.1.57

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.
@@ -9,8 +9,8 @@
9
9
 
10
10
  升级 = 在标准 mgmt run 里跑 `npm install -g codebee@latest`(日志实时落盘、
11
11
  SSE 可看进度)。npm 替换的是包目录文件,当前进程已加载进内存不受影响,装完后由
12
- 「重启」换新代码:新进程先等旧端口释放再 bind(Windows SO_REUSEADDR 允许双 LISTEN
13
- 同时存在,必须先验旧进程真退了),旧进程发送完重启响应后自退。
12
+ 自动重启换新代码:新进程先等旧端口释放再 bind(Windows SO_REUSEADDR 允许双
13
+ LISTEN 同时存在,必须先验旧进程真退了),旧进程发送完重启响应后自退。
14
14
 
15
15
  安全:两处子进程命令的 argv 均为**行内字面量列表**(可执行文件与全部参数不来自任何
16
16
  外部输入;重启仅透传 argparse 校验过的整型端口),shell 全程 False,绝不拼接用户输入。
@@ -170,7 +170,7 @@ def apply_upgrade(port=None):
170
170
  port=服务端口:升级成功且版本真变时会自动就地重启(用户拍板 2026-09-21:
171
171
  升级完不该再要求手动点「重启服务生效」——旧进程滞留是 unknown api/界面
172
172
  闪烁/老宠物一类「升级了没生效」事故的总根子)。拿不到端口或运行环境不
173
- 具备时自动跳过,回落版本页的手动重启按钮。"""
173
+ 具备时才跳过自动重启。"""
174
174
  global _PENDING_PORT
175
175
  try:
176
176
  _PENDING_PORT = int(port) if port else None
@@ -237,10 +237,10 @@ def _log_note(log_path, text):
237
237
 
238
238
 
239
239
  def _maybe_auto_relaunch(old_pkg, log_path):
240
- """升级成功后的自动重启(三道守卫,任一不满足就回落手动按钮):
241
- ①知道服务端口(apply_upgrade 传入);②版本真的变了(同版本重装不折腾);
242
- ③没有用户任务在跑(jobs._alive 只剩本升级任务自己)——正在干活的任务
243
- 不能被升级重启打断,此时留给用户挑自己合适的时间手动重启。"""
240
+ """升级成功后的自动重启:
241
+ ①知道服务端口(apply_upgrade 传入);②版本真的变了(同版本重装不折腾);
242
+ ③等当前用户任务自然结束后再原子停止接单——正在干活的任务不能被升级
243
+ 重启打断,也不再要求用户找到一个可能不存在的手动重启按钮。"""
244
244
  import threading
245
245
  from . import jobs
246
246
  if not _PENDING_PORT:
@@ -250,26 +250,27 @@ def _maybe_auto_relaunch(old_pkg, log_path):
250
250
  if not old_pkg or new_pkg == old_pkg:
251
251
  _log_note(log_path, "版本未变化(%s),无需重启" % (new_pkg or "?"))
252
252
  return
253
- if getattr(jobs, "_alive", 0) > 1:
254
- _log_note(log_path, "检测到还有 %d 个任务在运行,不自动重启——"
255
- "完成后请在版本页手动点「重启服务生效」" % (jobs._alive - 1))
256
- return
257
- port = _PENDING_PORT
253
+ port = _PENDING_PORT
258
254
 
259
255
  def _go():
260
256
  drain_started = False
257
+ wait_noted = False
261
258
  try:
262
259
  time.sleep(3.0) # 留出日志收尾/浏览器看到「升级完成」的窗口
263
- drain_started = jobs.begin_restart_drain()
264
- if not drain_started:
265
- _log_note(log_path, "延时窗口内有新任务进入,不自动重启——"
266
- "完成后请在版本页手动点「重启服务生效」")
267
- return
260
+ while not drain_started:
261
+ if (not wait_noted and
262
+ (getattr(jobs, "_alive", 0) > 1 or
263
+ getattr(jobs, "_chat_alive", 0) > 0)):
264
+ _log_note(log_path, "等待当前任务完成后自动重启服务…")
265
+ wait_noted = True
266
+ drain_started = jobs.begin_restart_drain()
267
+ if not drain_started:
268
+ jobs.wait_for_idle(timeout=1.0)
268
269
  _log_note(log_path, "自动重启服务以应用新版本 %s …" % new_pkg)
269
270
  if relaunch(port):
270
271
  self_quit()
271
272
  except Exception:
272
- log.exception("selfupdate: 自动重启失败,请在版本页手动重启")
273
+ log.exception("selfupdate: 自动重启失败")
273
274
  finally:
274
275
  # 正常 self_quit 会直接结束进程;若拉起失败、异常或测试替身返回,必须
275
276
  # 释放停止接单闸,避免当前实例永久拒绝新任务。
@@ -19,14 +19,19 @@ _PATTERNS = [
19
19
  (r"\beval\s*\(", "代码执行", "动态 eval 执行任意代码"),
20
20
  (r"\bexec\s*\(", "代码执行", "exec 执行任意代码"),
21
21
  (r"subprocess|os\.system|Popen", "代码执行", "起子进程执行命令"),
22
+ (r"child_process", "代码执行", "Node.js 子进程(可能执行任意命令)"),
22
23
  # 数据外发
23
24
  (r"https?://(?!api\.|docs\.|github\.com|raw\.githubusercontent)[a-z0-9.-]+/(upload|collect|track|ingest|webhook|callback)",
24
25
  "数据外发", "向非常规端点上传/回调数据"),
25
26
  (r"requests\.post|urllib\.request|fetch\s*\(", "网络请求", "发起网络请求(确认目标可信)"),
27
+ (r"base64.{0,10}(decode|b64decode|atob)", "数据外发",
28
+ "base64 解码(可能隐藏混淆载荷)"),
26
29
  # 敏感信息读取
27
30
  (r"os\.environ|process\.env|getenv", "环境读取", "读取环境变量(可能带走密钥)"),
28
31
  (r"\.ssh/|\.aws/|\.npmrc|\.gitconfig|credentials|\.env\b", "敏感文件", "触碰凭据/密钥文件路径"),
29
32
  (r"keychain|credential manager|dpapi", "敏感文件", "访问系统凭据库"),
33
+ (r"\.claude/|\.zcode/|\.kimi-code/|\.codebee/", "敏感文件",
34
+ "触碰 AI 助手配置目录(可能篡改系统提示词或模型绑定)"),
30
35
  # 提示注入特征
31
36
  (r"(ignore|disregard|forget).{0,30}(previous|above|prior|all).{0,20}(instruction|prompt|rule)",
32
37
  "提示注入", "指令覆盖话术(试图无视既有规则)"),
package/app/core/store.py CHANGED
@@ -28,11 +28,11 @@ _RUNS = {}
28
28
  # 长跑也不会无限增长(finish_step 里调 clear_stream_state)。
29
29
  _STREAM_LOCK = threading.Lock()
30
30
  _STREAM_TS = {}
31
- LIVE_TEXT_MAX = 20000 # 单步骤实时思考/正文的保留上限(与 builtin_agent 同口径)
32
-
33
- DEFAULT_TASK_TIMEOUT_S = 3600
34
- MIN_TASK_TIMEOUT_S = 1
35
- MAX_TASK_TIMEOUT_S = 7 * 24 * 3600
31
+ LIVE_TEXT_MAX = 20000 # 单步骤实时思考/正文的保留上限(与 builtin_agent 同口径)
32
+
33
+ DEFAULT_TASK_TIMEOUT_S = 3600
34
+ MIN_TASK_TIMEOUT_S = 1
35
+ MAX_TASK_TIMEOUT_S = 7 * 24 * 3600
36
36
 
37
37
  # ---------------------------------------------------------------- 状态版本(SSE 事件驱动)
38
38
  # 任何落盘写都算状态变化:SSE 连接等版本号变化才构建/推送全量状态,
@@ -61,38 +61,38 @@ def wait_state_change(last_ver, timeout):
61
61
  return _STATE_VER
62
62
 
63
63
 
64
- def _new_id(prefix):
65
- stamp = time.strftime("%Y%m%d-%H%M%S")
66
- # 10k random suffixes are not unique under a burst of task/run creation.
67
- # Reserve against both indexes and disk so a collision cannot silently
68
- # overwrite a queued run and make one of several submissions disappear.
69
- with LOCK:
70
- for _ in range(100):
71
- candidate = "%s-%s-%04d" % (prefix, stamp, secrets.randbelow(10000))
72
- if candidate in _TASKS or candidate in _RUNS:
73
- continue
74
- if (paths.TASKS_DIR / (candidate + ".json")).exists():
75
- continue
76
- if (paths.RUNS_DIR / candidate).exists():
77
- continue
78
- return candidate
79
- raise RuntimeError("无法生成唯一记录 ID")
80
-
81
-
82
- def _safe_name(s):
83
- return re.sub(r"[^0-9A-Za-z_-]+", "-", str(s))[:40].strip("-") or "x"
84
-
85
-
86
- def _task_timeout_s(value=None):
87
- """Resolve one bounded wall-clock budget for a task."""
88
- raw = value
89
- if raw in (None, ""):
90
- raw = os.environ.get("TUTTI_TASK_TIMEOUT_S", DEFAULT_TASK_TIMEOUT_S)
91
- try:
92
- seconds = int(float(raw))
93
- except (TypeError, ValueError):
94
- seconds = DEFAULT_TASK_TIMEOUT_S
95
- return max(MIN_TASK_TIMEOUT_S, min(MAX_TASK_TIMEOUT_S, seconds))
64
+ def _new_id(prefix):
65
+ stamp = time.strftime("%Y%m%d-%H%M%S")
66
+ # 10k random suffixes are not unique under a burst of task/run creation.
67
+ # Reserve against both indexes and disk so a collision cannot silently
68
+ # overwrite a queued run and make one of several submissions disappear.
69
+ with LOCK:
70
+ for _ in range(100):
71
+ candidate = "%s-%s-%04d" % (prefix, stamp, secrets.randbelow(10000))
72
+ if candidate in _TASKS or candidate in _RUNS:
73
+ continue
74
+ if (paths.TASKS_DIR / (candidate + ".json")).exists():
75
+ continue
76
+ if (paths.RUNS_DIR / candidate).exists():
77
+ continue
78
+ return candidate
79
+ raise RuntimeError("无法生成唯一记录 ID")
80
+
81
+
82
+ def _safe_name(s):
83
+ return re.sub(r"[^0-9A-Za-z_-]+", "-", str(s))[:40].strip("-") or "x"
84
+
85
+
86
+ def _task_timeout_s(value=None):
87
+ """Resolve one bounded wall-clock budget for a task."""
88
+ raw = value
89
+ if raw in (None, ""):
90
+ raw = os.environ.get("TUTTI_TASK_TIMEOUT_S", DEFAULT_TASK_TIMEOUT_S)
91
+ try:
92
+ seconds = int(float(raw))
93
+ except (TypeError, ValueError):
94
+ seconds = DEFAULT_TASK_TIMEOUT_S
95
+ return max(MIN_TASK_TIMEOUT_S, min(MAX_TASK_TIMEOUT_S, seconds))
96
96
 
97
97
 
98
98
  # ---------------------------------------------------------------- 任务
@@ -158,8 +158,8 @@ def create_task(payload):
158
158
  "status": "created",
159
159
  }
160
160
  thinking = str(payload.get("thinking") or "standard").strip().lower()
161
- task["thinking"] = thinking if thinking in ("auto", "low", "standard", "high") else "auto"
162
- task["timeout_s"] = _task_timeout_s(payload.get("timeout_s"))
161
+ task["thinking"] = thinking if thinking in ("auto", "low", "standard", "high") else "auto"
162
+ task["timeout_s"] = _task_timeout_s(payload.get("timeout_s"))
163
163
  if flow["engine"] == "direct":
164
164
  task["direct_provider_id"] = _text(payload.get("direct_provider_id"),
165
165
  "direct_provider_id")[:80]
@@ -546,7 +546,7 @@ def load_all():
546
546
  _save_json(paths.TASKS_DIR / (t["id"] + ".json"), t)
547
547
  continue
548
548
  latest = max(runs, key=lambda r: r["id"])
549
- if latest.get("status") in ("done", "failed", "cancelled", "timeout"):
549
+ if latest.get("status") in ("done", "failed", "cancelled", "timeout"):
550
550
  t["status"] = latest["status"]
551
551
  _save_json(paths.TASKS_DIR / (t["id"] + ".json"), t)
552
552
 
@@ -562,7 +562,7 @@ def set_run_estimator(estimator):
562
562
  _RUN_ESTIMATOR = estimator if callable(estimator) else None
563
563
 
564
564
 
565
- def create_run(kind, title, task_id=None, entry_id=None, op=None):
565
+ def create_run(kind, title, task_id=None, entry_id=None, op=None):
566
566
  run = {
567
567
  "id": _new_id("r" if kind == "orchestration" else "m"),
568
568
  "kind": kind, # orchestration | mgmt
@@ -570,14 +570,14 @@ def create_run(kind, title, task_id=None, entry_id=None, op=None):
570
570
  "task_id": task_id, "entry_id": entry_id, "op": op,
571
571
  "status": "queued", "steps": [], "messages": [],
572
572
  "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
573
- "started_at": None, "ended_at": None,
574
- "deadline_at": None,
573
+ "started_at": None, "ended_at": None,
574
+ "deadline_at": None,
575
575
  "cost_usd": 0.0, "tokens": 0, "error": "",
576
576
  "verdict": None, "summary": "",
577
577
  }
578
- if kind == "orchestration" and task_id:
579
- task = get_task(task_id) or {}
580
- run["deadline_at"] = time.time() + _task_timeout_s(task.get("timeout_s"))
578
+ if kind == "orchestration" and task_id:
579
+ task = get_task(task_id) or {}
580
+ run["deadline_at"] = time.time() + _task_timeout_s(task.get("timeout_s"))
581
581
  try:
582
582
  eta = _RUN_ESTIMATOR(
583
583
  task_type=task.get("type") or "", mode=task.get("mode") or "auto",
@@ -661,6 +661,37 @@ def task_runs(task_id):
661
661
  return runs
662
662
 
663
663
 
664
+ def task_runs_page(task_id, offset=0, limit=3):
665
+ """分页读取任务运行,只复制当前页;全量轻统计在详情打开时按需计算。"""
666
+ with LOCK:
667
+ runs = [r for r in _RUNS.values() if r.get("task_id") == task_id]
668
+ runs.sort(key=lambda run: run["id"], reverse=True)
669
+ page = [dict(run) for run in runs[offset:offset + limit]]
670
+ total_steps = settled_steps = total_tokens = 0
671
+ total_cost = 0.0
672
+ for run in runs:
673
+ steps = run.get("steps") or []
674
+ total_steps += len(steps)
675
+ settled_steps += sum(1 for step in steps
676
+ if step.get("status") not in ("running", "queued"))
677
+ try:
678
+ total_cost += float(run.get("cost_usd") or 0)
679
+ except (TypeError, ValueError):
680
+ pass
681
+ try:
682
+ total_tokens += int(run.get("tokens") or 0)
683
+ except (TypeError, ValueError):
684
+ pass
685
+ return {
686
+ "runs": page,
687
+ "total": len(runs),
688
+ "offset": offset,
689
+ "has_more": offset + len(page) < len(runs),
690
+ "totals": {"steps": total_steps, "settled_steps": settled_steps,
691
+ "cost_usd": total_cost, "tokens": total_tokens},
692
+ }
693
+
694
+
664
695
  def task_side(task_id):
665
696
  """任务检查器(右缘停靠列)的轻量聚合:最新 run 摘要 + 进度步骤 +
666
697
  git 分支/裁决状态 + 变更行级统计 + 成品文件。
@@ -762,27 +793,27 @@ def update_run(run_id, expected_status=None, **fields):
762
793
  # 这套在落终态瞬间即时收口——打磨组长被取消/异常打断时 UI 不闪
763
794
  # 假「工作中」)。run 终态后不可能还有步骤在真跑。
764
795
  st = fields.get("status")
765
- if st in ("done", "failed", "cancelled", "timeout"):
766
- end_s = time.strftime("%H:%M:%S")
767
- for s in run["steps"]:
768
- if s.get("status") in ("queued", "running"):
769
- s["status"] = "timeout" if st == "timeout" else "cancelled"
770
- s["ended_at"] = s.get("ended_at") or end_s
771
- if not s.get("summary"):
772
- s["summary"] = ("步骤未正常收尾(任务总时限已到)"
773
- if st == "timeout" else
774
- "步骤未正常收尾(终态自动恢复)")
796
+ if st in ("done", "failed", "cancelled", "timeout"):
797
+ end_s = time.strftime("%H:%M:%S")
798
+ for s in run["steps"]:
799
+ if s.get("status") in ("queued", "running"):
800
+ s["status"] = "timeout" if st == "timeout" else "cancelled"
801
+ s["ended_at"] = s.get("ended_at") or end_s
802
+ if not s.get("summary"):
803
+ s["summary"] = ("步骤未正常收尾(任务总时限已到)"
804
+ if st == "timeout" else
805
+ "步骤未正常收尾(终态自动恢复)")
775
806
  _save_json(paths.RUNS_DIR / run_id / "run.json", run)
776
807
  # 状态回填:起跑与结束都同步任务状态。只回填终态的话,run 在跑、
777
808
  # 任务永远显示「排队中」(2026-09-18 实案:「# 重写·续」run 已 running
778
809
  # 写了一小时,任务卡 queued,用户以为卡死连点重试)。
779
- if st in ("running", "done", "failed", "cancelled", "timeout"):
810
+ if st in ("running", "done", "failed", "cancelled", "timeout"):
780
811
  tid = run.get("task_id")
781
812
  task = _TASKS.get(tid) if tid else None
782
813
  if task:
783
814
  task["status"] = st
784
815
  _save_json(paths.TASKS_DIR / (tid + ".json"), task)
785
- if st in ("done", "failed", "cancelled", "timeout"):
816
+ if st in ("done", "failed", "cancelled", "timeout"):
786
817
  # run_end 钩子(2026-09-22):终态即触发,后台跑副作用型脚本
787
818
  # (回写知识库等);失败/超时静默,绝不拖慢收尾路径
788
819
  try:
@@ -873,17 +904,17 @@ def recover_orphaned_runs():
873
904
  step_now = time.strftime("%H:%M:%S")
874
905
  with LOCK:
875
906
  for r in _RUNS.values():
876
- if r.get("status") not in ("done", "failed", "cancelled", "timeout"):
877
- continue
907
+ if r.get("status") not in ("done", "failed", "cancelled", "timeout"):
908
+ continue
878
909
  changed = False
879
910
  for s in r.get("steps") or []:
880
911
  if s.get("status") in ("queued", "running"):
881
- s["status"] = "timeout" if r.get("status") == "timeout" else "cancelled"
912
+ s["status"] = "timeout" if r.get("status") == "timeout" else "cancelled"
882
913
  s["ended_at"] = s.get("ended_at") or step_now
883
914
  if not s.get("summary"):
884
- s["summary"] = ("步骤未正常收尾(任务总时限已到)"
885
- if r.get("status") == "timeout" else
886
- "步骤未正常收尾(取消/中断自动恢复)")
915
+ s["summary"] = ("步骤未正常收尾(任务总时限已到)"
916
+ if r.get("status") == "timeout" else
917
+ "步骤未正常收尾(取消/中断自动恢复)")
887
918
  changed = True
888
919
  if changed:
889
920
  _save_json(paths.RUNS_DIR / r["id"] / "run.json", r)
@@ -329,6 +329,10 @@ _TOKEN = {"v": "", "at": 0.0}
329
329
  _APIBASE = {}
330
330
  # 通道自适应:原始地址 → "rest"(≥15 REST v1)/ "old"(老版 module-method JSON 接口)
331
331
  _MODE = {}
332
+ # web 端路由形态:原始地址 → "pathinfo"(伪静态 bug-view-N.html,官方默认)
333
+ # / "get"(index.php?m=bug&f=view&bugID=N)。bug 详情链接用(2026-09-23 真机实证
334
+ # 伪静态部署对 GET 式链接不路由)。
335
+ _WEBSTYLE = {}
332
336
  _OLD = {"api": "", "sid": "", "at": 0.0}
333
337
  _OLD_FORM = {"form": ""} # 命中的密码形态 "plain" | "md5chain",缓存避免重复试
334
338
  _REST_LAST_ERR = {"msg": ""}
@@ -349,6 +353,37 @@ def resolved_base_url(base_url):
349
353
  return re.sub(r"/api\.php/v1$", "", hit)
350
354
 
351
355
 
356
+ def probe_web_style(base_url):
357
+ """探测禅道 web 端路由形态(bug 详情链接用):GET web 根(裸根 + 常见 /zentao
358
+ 子路径都试),看登录跳转里是伪静态路由(user-login-xxx.html)还是 GET 式
359
+ (index.php?m=user)。尽力而为:任何网络/守卫异常回空串,绝不影响登录与
360
+ 扫描主流程;探出即缓存。"""
361
+ try:
362
+ b = _raw_base(base_url)
363
+ except ZenError:
364
+ return ""
365
+ if _WEBSTYLE.get(b):
366
+ return _WEBSTYLE[b]
367
+ style = ""
368
+ for root in (b + "/", b + "/zentao/"):
369
+ body = ""
370
+ try:
371
+ req = urllib.request.Request(_guard_url(root), headers={"Accept": "text/html"})
372
+ with _no_redirect_opener().open(req, timeout=HTTP_TIMEOUT) as r:
373
+ body = (r.read(65536) or b"").decode("utf-8", "replace")
374
+ except Exception:
375
+ body = ""
376
+ if "user-login-" in body or re.search(r"[a-z]+-view-\d+[^\"]*\.html", body):
377
+ style = "pathinfo"
378
+ elif "index.php?m=" in body:
379
+ style = "get"
380
+ if style:
381
+ break
382
+ if style:
383
+ _WEBSTYLE[b] = style
384
+ return style
385
+
386
+
352
387
  def _raw_base(base_url):
353
388
  b = str(base_url or "").strip().rstrip("/")
354
389
  if not b:
@@ -415,6 +450,7 @@ def _rest_login(base_url, account, password):
415
450
  _APIBASE[b] = api
416
451
  _TOKEN["v"] = tok
417
452
  _TOKEN["at"] = time.time()
453
+ probe_web_style(b) # bug 链接形态探测,失败不影响登录
418
454
  return tok
419
455
  # HTTP 200 但没有 token:REST 在但不认账密(如只认应用 code 的部署)
420
456
  _REST_LAST_ERR["msg"] = "REST 接口不认账密(%s)" % (
@@ -592,6 +628,7 @@ def _old_login(cfg, rest_err=""):
592
628
  _OLD.update(api=api, sid=sid, at=time.time())
593
629
  _OLD_FORM["form"] = form
594
630
  _MODE[b] = "old"
631
+ probe_web_style(b) # bug 链接形态探测,失败不影响登录
595
632
  return
596
633
  last = err
597
634
  break # 同一台服务,账密结果与子路径无关,别再烧尝试次数
@@ -897,6 +934,40 @@ def list_bugs(cfg, product_id, profile=None):
897
934
  return out[:MAX_BUGS]
898
935
 
899
936
 
937
+ def weekly_brief(limit=12):
938
+ """本周工作素材(工作汇报起草注入用,2026-09-23 Weekly Report Generator 借鉴
939
+ ——「从工作系统取数入素材」):分配给我的活跃任务 + 指派给我的活跃 Bug 摘要。
940
+ 尽力而为:未配置/不可达/响应形状不对一律返回空串,绝不阻塞汇报起草。
941
+ 多产品档案逐个聚合(同 _scan 口径),条目总量封顶 limit*2。"""
942
+ lines = []
943
+ try:
944
+ cfg = _cfg()
945
+ for profile in _profiles(cfg):
946
+ assigned = str(profile.get("assigned_to") or "").strip()
947
+ if not assigned:
948
+ continue
949
+ try:
950
+ d = _call("GET", "/tasks?%s" % urllib.parse.urlencode(
951
+ {"assignedTo": assigned, "limit": limit}), cfg=cfg)
952
+ tasks = d.get("tasks") if isinstance(d, dict) else None
953
+ for t in (tasks or [])[:limit]:
954
+ if isinstance(t, dict) and t.get("status") in ("wait", "doing"):
955
+ lines.append("- [任务#%s] %s(%s)" % (
956
+ t.get("id"), t.get("name") or "", t.get("status") or ""))
957
+ except Exception:
958
+ pass
959
+ try:
960
+ for b in list_bugs(cfg, profile.get("product"), profile)[:limit]:
961
+ lines.append("- [Bug#%s] %s(严重级 %s)" % (
962
+ b.get("id"), b.get("title") or "",
963
+ b.get("severity") or b.get("status") or ""))
964
+ except Exception:
965
+ pass
966
+ except Exception:
967
+ return ""
968
+ return "\n".join(lines[:limit * 2])
969
+
970
+
900
971
  def fetch_modules(product_id):
901
972
  """拉产品模块清单(模块路由配置辅助)。接口不存在/失败报人话,提示手填 ID。"""
902
973
  _ensure_loaded()
@@ -1965,6 +2036,24 @@ def save_config(patch):
1965
2036
  return view()["config"]
1966
2037
 
1967
2038
 
2039
+ def bug_web_base(cfg=None):
2040
+ """给前端拼 bug 详情页用的 web 根:探测出的有效地基(含自动补的 /zentao 子路径)
2041
+ 优先,没探测过回落用户填的原始地址。返回 stripped 根或空串。"""
2042
+ c = dict(cfg) if cfg else _cfg()
2043
+ hit = resolved_base_url(c.get("base_url"))
2044
+ if hit:
2045
+ return hit
2046
+ b = re.sub(r"/api\.php/v1$", "", str(c.get("base_url") or "").strip().rstrip("/"))
2047
+ return b if b.startswith(("http://", "https://")) else ""
2048
+
2049
+
2050
+ def bug_link_style(cfg=None):
2051
+ """bug 详情链接形态:探测缓存优先,没探出来按伪静态(官方默认形态)。"""
2052
+ c = dict(cfg) if cfg else _cfg()
2053
+ b = str(c.get("base_url") or "").strip().rstrip("/")
2054
+ return _WEBSTYLE.get(b) or "pathinfo"
2055
+
2056
+
1968
2057
  def view():
1969
2058
  """前端视图:配置脱敏(password 只回是否已设)+ claims 列表(新在前)。"""
1970
2059
  _ensure_loaded()
@@ -1973,9 +2062,12 @@ def view():
1973
2062
  has_pw = bool(cfg.get("password"))
1974
2063
  cfg["password"] = ""
1975
2064
  cfg["has_password"] = has_pw
2065
+ bug_base = bug_web_base(cfg)
1976
2066
  claims = sorted(_STATE["claims"].values(),
1977
2067
  key=lambda c: str(c.get("claimed_at") or ""), reverse=True)
1978
2068
  return {"config": cfg,
2069
+ "bug_base": bug_base,
2070
+ "bug_style": bug_link_style(cfg),
1979
2071
  "claims": [dict(c) for c in claims],
1980
2072
  "last_scan": _STATE.get("last_scan") or "",
1981
2073
  "next_scan": _STATE.get("next_scan") or "",
@@ -1990,6 +2082,7 @@ def _test_reset():
1990
2082
  _STATE["last_scan"] = _STATE["next_scan"] = _STATE["last_error"] = ""
1991
2083
  _reset_token()
1992
2084
  _APIBASE.clear()
2085
+ _WEBSTYLE.clear()
1993
2086
  _MODE.clear()
1994
2087
  _OLD.update(api="", sid="", at=0.0)
1995
2088
  _OLD_FORM["form"] = ""