codebee 0.1.56 → 0.1.58

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.
@@ -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
  "提示注入", "指令覆盖话术(试图无视既有规则)"),
@@ -65,13 +70,17 @@ def scan_text(text, max_findings=12):
65
70
  def risk_label(findings):
66
71
  """发现列表 → 风险标签(装前提示行用)。
67
72
 
68
- 高危(代码执行/敏感文件/可疑意图)任一命中 = 高风险;否则有发现 = 注意;
69
- 空 = 干净。"""
73
+ 三级:高危(代码执行/敏感文件/可疑意图/数据外发/base64 解码)任一
74
+ 命中 = 高风险;中危类(网络请求/环境读取/提示注入/越权人格)≥2 类
75
+ 同时命中 = 中风险;有发现 = 注意;空 = 干净。"""
70
76
  if not findings:
71
77
  return ""
72
78
  cats = {f["category"] for f in findings}
73
79
  if cats & {"代码执行", "敏感文件", "可疑意图", "数据外发"}:
74
80
  return "⚠ 高风险"
81
+ medium_cats = cats & {"网络请求", "环境读取", "提示注入", "越权人格"}
82
+ if len(medium_cats) >= 2:
83
+ return "⚠ 中风险"
75
84
  return "△ 注意"
76
85
 
77
86
 
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()
@@ -1976,6 +2047,13 @@ def bug_web_base(cfg=None):
1976
2047
  return b if b.startswith(("http://", "https://")) else ""
1977
2048
 
1978
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
+
1979
2057
  def view():
1980
2058
  """前端视图:配置脱敏(password 只回是否已设)+ claims 列表(新在前)。"""
1981
2059
  _ensure_loaded()
@@ -1989,6 +2067,7 @@ def view():
1989
2067
  key=lambda c: str(c.get("claimed_at") or ""), reverse=True)
1990
2068
  return {"config": cfg,
1991
2069
  "bug_base": bug_base,
2070
+ "bug_style": bug_link_style(cfg),
1992
2071
  "claims": [dict(c) for c in claims],
1993
2072
  "last_scan": _STATE.get("last_scan") or "",
1994
2073
  "next_scan": _STATE.get("next_scan") or "",
@@ -2003,6 +2082,7 @@ def _test_reset():
2003
2082
  _STATE["last_scan"] = _STATE["next_scan"] = _STATE["last_error"] = ""
2004
2083
  _reset_token()
2005
2084
  _APIBASE.clear()
2085
+ _WEBSTYLE.clear()
2006
2086
  _MODE.clear()
2007
2087
  _OLD.update(api="", sid="", at=0.0)
2008
2088
  _OLD_FORM["form"] = ""
package/app/main.py CHANGED
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
  import argparse
9
9
  import json
10
10
  import logging
11
+ import math
11
12
  import os
12
13
  import re
13
14
  import socket
@@ -434,7 +435,15 @@ class Handler(BaseHTTPRequestHandler):
434
435
  # 任务级详情用:该任务全部 run(含 steps),不受前端 run 窗口限制
435
436
  if not store.get_task(m.group(1)):
436
437
  return self._json(404, {"error": "not found"})
437
- return self._json(200, {"runs": store.task_runs(m.group(1))})
438
+ query = parse_qs(urlparse(self.path).query)
439
+ if "limit" not in query:
440
+ return self._json(200, {"runs": store.task_runs(m.group(1))})
441
+ try:
442
+ limit = max(1, min(20, int((query.get("limit") or [3])[0])))
443
+ offset = max(0, int((query.get("offset") or [0])[0]))
444
+ except (TypeError, ValueError):
445
+ return self._json(400, {"error": "invalid pagination"})
446
+ return self._json(200, store.task_runs_page(m.group(1), offset, limit))
438
447
  m = re.match(r"^/api/tasks/([^/]+)/bible$", path)
439
448
  if m:
440
449
  # 故事圣经:查看(无令牌豁免走 _authed 已过;本机免令牌)
@@ -1988,6 +1997,13 @@ class Handler(BaseHTTPRequestHandler):
1988
1997
  verdict = latest.get("verdict") or {}
1989
1998
  route = latest.get("route") or {}
1990
1999
 
2000
+ def _seconds(value):
2001
+ try:
2002
+ value = float(value)
2003
+ except (OverflowError, TypeError, ValueError):
2004
+ return None
2005
+ return value if math.isfinite(value) and value >= 0 else None
2006
+
1991
2007
  def _sec(a, b):
1992
2008
  try:
1993
2009
  return max(0, int(time.mktime(time.strptime(b, "%Y-%m-%d %H:%M:%S")) -
@@ -1995,6 +2011,28 @@ class Handler(BaseHTTPRequestHandler):
1995
2011
  except Exception:
1996
2012
  return None
1997
2013
  t0 = latest.get("started_at") or latest.get("created_at")
2014
+ # Run timestamps are stored only to whole seconds; two timestamps in the
2015
+ # same second therefore produce a misleading 0 even when a CLI step ran
2016
+ # for a minute. Prefer the precise persisted duration, then the sum of
2017
+ # step durations, and use wall-clock timestamps only as a legacy fallback.
2018
+ duration_s = _seconds(latest.get("duration_s"))
2019
+ step_durations = []
2020
+ for step in latest.get("steps") or []:
2021
+ if isinstance(step, dict):
2022
+ value = _seconds(step.get("duration_s"))
2023
+ if value is not None:
2024
+ step_durations.append(value)
2025
+ step_duration_s = round(sum(step_durations), 1) if step_durations else None
2026
+ if duration_s is None or duration_s == 0:
2027
+ if step_duration_s is not None and step_duration_s > 0:
2028
+ duration_s = step_duration_s
2029
+ else:
2030
+ timestamp_duration_s = (_sec(t0, latest.get("ended_at") or "")
2031
+ if t0 and latest.get("ended_at") else None)
2032
+ if timestamp_duration_s is not None:
2033
+ duration_s = timestamp_duration_s
2034
+ elif step_duration_s is not None:
2035
+ duration_s = step_duration_s
1998
2036
  wd, files = "", []
1999
2037
  try:
2000
2038
  wd, files = store.run_artifacts(latest.get("id") or "", limit=12)
@@ -2007,8 +2045,7 @@ class Handler(BaseHTTPRequestHandler):
2007
2045
  "error": (latest.get("error") or "") if st in ("failed", "timeout") else "",
2008
2046
  "executor": route.get("implementer") or verdict.get("impl") or "",
2009
2047
  "turns": verdict.get("turns") or 0,
2010
- "duration_s": _sec(t0, latest.get("ended_at") or "")
2011
- if t0 and latest.get("ended_at") else None,
2048
+ "duration_s": duration_s,
2012
2049
  "workdir": wd,
2013
2050
  "files": files,
2014
2051
  }
@@ -2504,6 +2541,19 @@ def main():
2504
2541
  help="自更新重启用:先等旧实例释放端口再启动(Windows 双 LISTEN 防护)")
2505
2542
  args = parser.parse_args()
2506
2543
 
2544
+ # 测试实例禁止使用默认端口 8765(2026-09-23 实案:代理测试忘带 --port,
2545
+ # 启动清场把真实服务当旧实例杀掉后鸠占鹊巢)。测试请显式指定独立端口。
2546
+ try:
2547
+ from core.manager import tmp_data_no_home_write as _tdnhw
2548
+ except Exception:
2549
+ _tdnhw = None
2550
+ if _tdnhw and _tdnhw() and args.port == 8765 and "--port" not in sys.argv:
2551
+ raise SystemExit("[CodeBee] 测试实例禁止使用默认端口 8765(会与真实服务冲突),"
2552
+ "请显式指定 --port <独立端口>")
2553
+ if _tdnhw and _tdnhw() and args.port == 8765:
2554
+ print("[CodeBee] ⚠ 测试实例正占用默认端口 8765——真实服务将无法启动;"
2555
+ "请改用独立端口", flush=True)
2556
+
2507
2557
  # 启动每步都落一行进度(flush 强制落屏):冷启动在慢盘/杀软扫描下可能几十秒,
2508
2558
  # 不打印会让用户以为卡死(真实案例:npm 装完首启只看到横幅像挂了)。
2509
2559
  # 看门狗:任何阶段卡超过 20 秒,自动把所有线程堆栈打到控制台(每 20s 重复)——
@@ -2665,8 +2715,16 @@ def main():
2665
2715
  from core import portscan as _ps, portguard as _pg
2666
2716
  _hint = ""
2667
2717
  try:
2668
- _cleared, _hint = _pg.clear_stale_port(
2669
- args.port, paths.APP_DIR / "main.py")
2718
+ from core.manager import tmp_data_no_home_write
2719
+ if tmp_data_no_home_write():
2720
+ # 测试实例不清场:TUTTI_DATA 在临时目录的进程若占用了真实
2721
+ # 端口(如 8765),清场会误杀生产服务(2026-09-23 实案)
2722
+ _cleared, _hint = False, "测试实例不执行端口清场"
2723
+ else:
2724
+ _cleared, _hint = _pg.clear_stale_port(
2725
+ args.port, paths.APP_DIR / "main.py")
2726
+ except Exception:
2727
+ _cleared, _hint = False,
2670
2728
  except Exception:
2671
2729
  _cleared, _hint = False, ""
2672
2730
  if _cleared: