coze_lab 0.1.26 → 0.1.28

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/index.js CHANGED
@@ -103,6 +103,18 @@ function successBox(lines) {
103
103
  // ─── 2. Script loading (scripts live as real files under ./scripts) ───────────
104
104
  const SCRIPTS_DIR = require('path').join(__dirname, 'scripts');
105
105
 
106
+ // OpenClaw 插件版本:来自 scripts/openclaw/package.json。写进 openclaw.json 的 pcfg,
107
+ // 参与 isOpenClawAlreadyInjected 幂等比对——插件代码升级(bump 该 version)后即使
108
+ // token/endpoint/workspace 不变也会被判定为“需更新”,强制重写插件文件 + npm install +
109
+ // gateway restart,避免旧插件 dist 滞留在云端 pluginDir 里不生效。
110
+ const OPENCLAW_PLUGIN_VERSION = (() => {
111
+ try {
112
+ return require('./scripts/openclaw/package.json').version || '';
113
+ } catch {
114
+ return '';
115
+ }
116
+ })();
117
+
106
118
  // Read a single script file (Python hook / refresh) as a UTF-8 string.
107
119
  function readScript(relPath) {
108
120
  return require('fs').readFileSync(require('path').join(SCRIPTS_DIR, relPath), 'utf8');
@@ -216,15 +228,21 @@ def _refresh_token(refresh_token: str) -> Optional[str]:
216
228
  req = urllib.request.Request(
217
229
  f"{_COZE_API}/api/permission/oauth2/token",
218
230
  data=payload,
219
- headers={"Content-Type": "application/json"},
231
+ headers={
232
+ "Content-Type": "application/json",
233
+ "x-tt-env": "ppe_cozelab",
234
+ "x-use-ppe": "1",
235
+ },
220
236
  )
221
237
  with urllib.request.urlopen(req, timeout=10) as resp:
222
238
  data = json.loads(resp.read())
223
239
  if data.get("access_token"):
240
+ existing = _load_credentials() or {}
224
241
  creds = {
225
242
  "access_token": data["access_token"],
226
243
  "refresh_token": data.get("refresh_token", refresh_token),
227
244
  "expires_at": data.get("expires_in", 0) * 1000, # unix timestamp in seconds
245
+ "workspace_id": existing.get("workspace_id", ""),
228
246
  }
229
247
  _save_credentials(creds)
230
248
  debug_log("Token refreshed successfully.")
@@ -233,22 +251,34 @@ def _refresh_token(refresh_token: str) -> Optional[str]:
233
251
  debug_log(f"Token refresh failed: {e}")
234
252
  return None
235
253
 
254
+ def _token_from_credentials() -> Optional[str]:
255
+ creds = _load_credentials()
256
+ if not creds:
257
+ return None
258
+ expires_at_sec = creds.get("expires_at", 0) / 1000
259
+ remaining = expires_at_sec - time.time()
260
+ if remaining > _REFRESH_THRESHOLD:
261
+ debug_log(f"Cached token valid, expires in {int(remaining)}s.")
262
+ return creds.get("access_token")
263
+ if creds.get("refresh_token"):
264
+ debug_log(f"Token expiring in {int(remaining)}s, refreshing...")
265
+ new_token = _refresh_token(creds["refresh_token"])
266
+ if new_token:
267
+ return new_token
268
+ debug_log("Refresh failed.")
269
+ return None
270
+
236
271
  def get_fresh_token() -> Optional[str]:
237
- """Return a valid access token, refreshing if needed. Falls back to env var."""
272
+ """Return a valid access token, refreshing if needed."""
273
+ env_token = os.environ.get("COZELOOP_API_TOKEN")
274
+ env_coze_token = os.environ.get("COZE_API_TOKEN")
275
+ is_cloud = os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes")
276
+ if is_cloud:
277
+ return env_token or env_coze_token or _token_from_credentials()
238
278
  creds = _load_credentials()
239
279
  if creds:
240
- expires_at_sec = creds.get("expires_at", 0) / 1000
241
- remaining = expires_at_sec - time.time()
242
- if remaining > _REFRESH_THRESHOLD:
243
- debug_log(f"Cached token valid, expires in {int(remaining)}s.")
244
- return creds["access_token"]
245
- if creds.get("refresh_token"):
246
- debug_log(f"Token expiring in {int(remaining)}s, refreshing...")
247
- new_token = _refresh_token(creds["refresh_token"])
248
- if new_token:
249
- return new_token
250
- debug_log("Refresh failed, falling back to env var.")
251
- return os.environ.get("COZELOOP_API_TOKEN")
280
+ return _token_from_credentials()
281
+ return env_token or env_coze_token
252
282
 
253
283
  # -------------------------------------------------------------------------
254
284
 
@@ -1334,6 +1364,125 @@ from typing import Optional, List, Dict, Any
1334
1364
  _COZELOOP_CLIENT_ID = "46371084383473718052118955183420.app.coze"
1335
1365
  _COZE_API = "https://api.coze.cn"
1336
1366
  _REFRESH_THRESHOLD = 10 * 60
1367
+ _DEFAULT_WORKSPACE_ID = "7644910356078837760" # hardcoded spaceID fallback
1368
+ _OTEL_SUFFIX = "/v1/loop/opentelemetry"
1369
+
1370
+
1371
+ # --- coze-context parsing -------------------------------------------------
1372
+ # User messages may embed a block like:
1373
+ # <coze-context>
1374
+ # account_id: 0
1375
+ # agent_id: 7644920552473395499
1376
+ # session_id: 7644919579054997796
1377
+ # message_id: 04dd5246-...
1378
+ # </coze-context>
1379
+ # We parse its key:value pairs and inject them into the trace.
1380
+
1381
+ _COZE_CTX_OPEN = "<coze-context>"
1382
+ _COZE_CTX_CLOSE = "</coze-context>"
1383
+
1384
+
1385
+ def parse_coze_context(text: str) -> Dict[str, str]:
1386
+ """Extract the LAST <coze-context> block's key:value pairs from text.
1387
+
1388
+ Returns {} if no block is present. Tag keys are prefixed with
1389
+ 'coze_' by the caller; here we return raw keys as written.
1390
+ """
1391
+ if not text or _COZE_CTX_OPEN not in text:
1392
+ return {}
1393
+ # Take the last occurrence (latest context wins).
1394
+ open_idx = text.rfind(_COZE_CTX_OPEN)
1395
+ close_idx = text.find(_COZE_CTX_CLOSE, open_idx)
1396
+ if close_idx == -1:
1397
+ return {}
1398
+ body = text[open_idx + len(_COZE_CTX_OPEN):close_idx]
1399
+ # The block may arrive with real newlines, OR with literal backslash-n
1400
+ # (e.g. when the whole message is an embedded JSON string that was never
1401
+ # un-escaped). Normalize both forms before splitting into lines.
1402
+ body = body.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\r", "\n")
1403
+ result: Dict[str, str] = {}
1404
+ for line in body.splitlines():
1405
+ line = line.strip()
1406
+ if not line or ":" not in line:
1407
+ continue
1408
+ key, _, value = line.partition(":")
1409
+ key = key.strip()
1410
+ value = value.strip()
1411
+ if key:
1412
+ result[key] = value
1413
+ return result
1414
+
1415
+
1416
+ def coze_context_tags(text: str) -> Dict[str, str]:
1417
+ """Return coze-context kv as trace tags, prefixed with 'coze_'."""
1418
+ return {f"coze_{k}": v for k, v in parse_coze_context(text).items()}
1419
+
1420
+
1421
+ def turn_coze_context(turn: Dict[str, Any]) -> Dict[str, str]:
1422
+ """Extract coze-context from a grouped turn with fallbacks for Codex rollout shapes."""
1423
+ texts = [turn.get("user_message_text", "")]
1424
+ for msg in turn.get("input_messages", []):
1425
+ if isinstance(msg, dict):
1426
+ texts.append(str(msg.get("content", "")))
1427
+ user_payload = turn.get("user_message")
1428
+ if isinstance(user_payload, dict):
1429
+ texts.append(extract_message_content_text(user_payload))
1430
+ for text in texts:
1431
+ ctx = parse_coze_context(text)
1432
+ if ctx:
1433
+ return ctx
1434
+ return {}
1435
+
1436
+
1437
+ # --- trace upload failure / logid capture ---------------------------------
1438
+ def _extract_logid(msg: str) -> str:
1439
+ """Pull the server logid out of an SDK error message, if present.
1440
+
1441
+ SDK failure messages embed it as 'logid=XXXX' (sometimes within brackets).
1442
+ """
1443
+ if not msg:
1444
+ return ""
1445
+ marker = "logid="
1446
+ idx = msg.find(marker)
1447
+ if idx == -1:
1448
+ return ""
1449
+ rest = msg[idx + len(marker):]
1450
+ logid = []
1451
+ for ch in rest:
1452
+ if ch.isalnum():
1453
+ logid.append(ch)
1454
+ else:
1455
+ break
1456
+ return "".join(logid)
1457
+
1458
+
1459
+ def _make_finish_event_processor(upload_events: Optional[List[str]] = None):
1460
+ """Return a trace_finish_event_processor that surfaces failures + logid.
1461
+
1462
+ The CozeLoop SDK calls this for each flush event; on failure we print the
1463
+ server logid to stderr so it can be handed to platform support for tracing
1464
+ the root cause (e.g. via \`bytedcli log get-logid-log <logid>\`).
1465
+ """
1466
+ def _processor(info):
1467
+ try:
1468
+ if not getattr(info, "is_event_fail", False):
1469
+ hook_log("upload success")
1470
+ return
1471
+ detail = getattr(info, "detail_msg", "") or ""
1472
+ if upload_events is not None:
1473
+ upload_events.append(detail or "trace export failed")
1474
+ logid = _extract_logid(detail)
1475
+ if logid:
1476
+ hook_log(f"upload failed logid={logid} detail={detail[:500]}")
1477
+ print(f"[CozeLoop] 上报失败 logid={logid} (可用 bytedcli log get-logid-log {logid} 排查)", file=sys.stderr)
1478
+ else:
1479
+ hook_log(f"upload failed detail={detail[:500]}")
1480
+ print(f"[CozeLoop] 上报失败: {detail[:300]}", file=sys.stderr)
1481
+ except Exception:
1482
+ pass
1483
+ return _processor
1484
+
1485
+
1337
1486
 
1338
1487
  def _get_credentials_path() -> Path:
1339
1488
  return Path.home() / ".cozeloop" / "credentials.json"
@@ -1363,15 +1512,21 @@ def _refresh_token(refresh_tok: str):
1363
1512
  req = urllib.request.Request(
1364
1513
  f"{_COZE_API}/api/permission/oauth2/token",
1365
1514
  data=payload,
1366
- headers={"Content-Type": "application/json"},
1515
+ headers={
1516
+ "Content-Type": "application/json",
1517
+ "x-tt-env": "ppe_cozelab",
1518
+ "x-use-ppe": "1",
1519
+ },
1367
1520
  )
1368
1521
  with urllib.request.urlopen(req, timeout=10) as resp:
1369
1522
  data = json.loads(resp.read())
1370
1523
  if data.get("access_token"):
1524
+ existing = _load_credentials() or {}
1371
1525
  creds = {
1372
1526
  "access_token": data["access_token"],
1373
1527
  "refresh_token": data.get("refresh_token", refresh_tok),
1374
- "expires_at": data.get("expires_in", 0) * 1000 # unix timestamp in seconds
1528
+ "expires_at": data.get("expires_in", 0) * 1000, # unix timestamp in seconds
1529
+ "workspace_id": existing.get("workspace_id", ""),
1375
1530
  }
1376
1531
  _save_credentials(creds)
1377
1532
  return creds["access_token"]
@@ -1379,37 +1534,168 @@ def _refresh_token(refresh_tok: str):
1379
1534
  pass
1380
1535
  return None
1381
1536
 
1537
+
1538
+ def _normalize_api_base_url(url: str) -> str:
1539
+ base = (url or "").strip().rstrip("/")
1540
+ if base.endswith(_OTEL_SUFFIX + "/v1/traces"):
1541
+ return base[:-len(_OTEL_SUFFIX + "/v1/traces")].rstrip("/")
1542
+ if base.endswith("/api/v1/loop/opentelemetry/v1/traces"):
1543
+ return base[:-len("/v1/loop/opentelemetry/v1/traces")].rstrip("/")
1544
+ if base.endswith(_OTEL_SUFFIX):
1545
+ return base[:-len(_OTEL_SUFFIX)].rstrip("/")
1546
+ if base.endswith("/api/v1/loop/opentelemetry"):
1547
+ return base[:-len("/v1/loop/opentelemetry")].rstrip("/")
1548
+ if base.endswith("/api/v1"):
1549
+ return base[:-len("/v1")].rstrip("/")
1550
+ return base
1551
+
1552
+
1553
+ def get_api_base_url() -> str:
1554
+ return _normalize_api_base_url(
1555
+ os.environ.get("COZELOOP_API_BASE_URL", "")
1556
+ )
1557
+
1558
+
1559
+ def _token_from_credentials():
1560
+ creds = _load_credentials()
1561
+ if not creds:
1562
+ return None
1563
+ remaining = creds.get("expires_at", 0) / 1000 - time.time()
1564
+ if remaining > _REFRESH_THRESHOLD:
1565
+ return creds.get("access_token")
1566
+ if creds.get("refresh_token"):
1567
+ new_token = _refresh_token(creds["refresh_token"])
1568
+ if new_token:
1569
+ return new_token
1570
+ return None
1571
+
1572
+
1382
1573
  def get_fresh_token():
1574
+ env_token = os.environ.get("COZELOOP_API_TOKEN")
1575
+ env_coze_token = os.environ.get("COZE_API_TOKEN")
1576
+ is_cloud = os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes")
1577
+ if is_cloud:
1578
+ return env_token or env_coze_token or _token_from_credentials()
1579
+
1580
+ # Local onboard used to write a short-lived access token into cozeloop.env.
1581
+ # If credentials exist but cannot be refreshed, do not fall back to that stale
1582
+ # env token; failing closed keeps state from advancing on a known-bad token.
1383
1583
  creds = _load_credentials()
1384
1584
  if creds:
1385
- remaining = creds.get("expires_at", 0) / 1000 - time.time()
1386
- if remaining > _REFRESH_THRESHOLD:
1387
- return creds["access_token"]
1388
- if creds.get("refresh_token"):
1389
- new_token = _refresh_token(creds["refresh_token"])
1390
- if new_token:
1391
- return new_token
1392
- return os.environ.get("COZELOOP_API_TOKEN")
1585
+ return _token_from_credentials()
1586
+ if env_token and not env_coze_token:
1587
+ token = _token_from_credentials()
1588
+ if token:
1589
+ return token
1590
+ return env_token
1591
+ if env_token:
1592
+ return env_token
1593
+ if env_coze_token:
1594
+ return env_coze_token
1595
+ return _token_from_credentials()
1393
1596
  # -------------------------------------------------------------------------
1394
1597
 
1395
1598
  # --- SDK Import ---
1396
- try:
1599
+ # 最低版本:set_finish_time 是 cozeloop SDK 0.1.25 才引入的方法,云端预装旧版(<=0.1.24)
1600
+ # 调用会抛 AttributeError 并使整条 trace 上报失败。统一用能力探测(hasattr)判定,与
1601
+ # _set_finish_time_safe 兜底口径一致,避免版本号字符串比较的边界问题。
1602
+ _MIN_COZELOOP_SPEC = "cozeloop>=0.1.28"
1603
+
1604
+
1605
+ def _cozeloop_capable():
1606
+ """已装 cozeloop 是否具备 set_finish_time 能力。未装/异常返回 None(无法判定)。"""
1607
+ try:
1608
+ import cozeloop # noqa: F401
1609
+ except ImportError:
1610
+ return None
1611
+ try:
1612
+ return hasattr(cozeloop.Span, "set_finish_time")
1613
+ except Exception:
1614
+ return False
1615
+
1616
+
1617
+ def _ensure_cozeloop_sdk():
1618
+ """确保 cozeloop 可 import 且尽量满足 set_finish_time 能力。
1619
+
1620
+ 返回 True 表示 cozeloop 可 import(不保证版本达标——能力不足时由
1621
+ _set_finish_time_safe 兜底,不阻断上报);返回 False 表示完全无法 import。
1622
+ """
1623
+ capable = _cozeloop_capable()
1624
+ if capable is True:
1625
+ return True
1626
+ # 已装但能力不足(capable is False)→ 需升级;未装(None)→ 需安装。
1627
+ import subprocess
1628
+ import importlib
1629
+ import site
1630
+ # 能力不足时强制升级到带下限的版本;未装时直接装下限版本。
1631
+ pkg = _MIN_COZELOOP_SPEC
1632
+ base_flags = ["--quiet", "--disable-pip-version-check", "--upgrade"]
1633
+ attempts = (
1634
+ [*base_flags, pkg],
1635
+ [*base_flags, "--break-system-packages", pkg],
1636
+ [*base_flags, "--break-system-packages", "--user", pkg],
1637
+ )
1638
+ for extra in attempts:
1639
+ try:
1640
+ subprocess.run(
1641
+ [sys.executable, "-m", "pip", "install", *extra],
1642
+ timeout=180, check=True,
1643
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
1644
+ )
1645
+ except Exception:
1646
+ continue
1647
+ try:
1648
+ importlib.reload(site)
1649
+ user_site = site.getusersitepackages()
1650
+ for p in ([user_site] if isinstance(user_site, str) else list(user_site)):
1651
+ if p and p not in sys.path:
1652
+ sys.path.insert(0, p)
1653
+ importlib.invalidate_caches()
1654
+ import cozeloop # noqa: F401
1655
+ print("[CozeLoop] cozeloop SDK installed/upgraded at runtime.", file=sys.stderr)
1656
+ return True
1657
+ except ImportError:
1658
+ continue
1659
+ # 升级没成功,但只要原本能 import(capable is False)就继续——兜底会处理能力缺失。
1660
+ return capable is False
1661
+
1662
+
1663
+ if _ensure_cozeloop_sdk():
1397
1664
  import cozeloop
1398
1665
  from cozeloop.spec.tracespec import (
1399
1666
  Runtime, ModelInput, ModelMessage, ModelToolChoice,
1400
1667
  ModelOutput, ModelChoice, ModelToolCall, ModelToolCallFunction,
1401
1668
  ModelMessagePart, ModelMessagePartType
1402
1669
  )
1403
- except ImportError:
1404
- print("Error: cozeloop SDK not found. Please install it with: pip install cozeloop", file=sys.stderr)
1670
+ else:
1671
+ print("Error: cozeloop SDK not found and auto-install failed. Try: pip install cozeloop", file=sys.stderr)
1405
1672
  sys.exit(1)
1406
1673
 
1407
1674
  # --- Configuration ---
1408
1675
  DEBUG = os.environ.get("CC_COZELOOP_DEBUG", "").lower() == "true"
1409
1676
 
1410
1677
 
1678
+ def _log_file_path() -> str:
1679
+ return os.environ.get("COZELOOP_HOOK_LOG", "").strip()
1680
+
1681
+
1682
+ def hook_log(message: str):
1683
+ """Append one diagnostic line to the hook log, if configured."""
1684
+ log_path = _log_file_path()
1685
+ if not log_path:
1686
+ return
1687
+ try:
1688
+ p = Path(log_path).expanduser()
1689
+ p.parent.mkdir(parents=True, exist_ok=True)
1690
+ with p.open("a", encoding="utf-8") as f:
1691
+ f.write(f"{datetime.now().isoformat()} {message}\n")
1692
+ except Exception:
1693
+ pass
1694
+
1695
+
1411
1696
  def debug_log(message: str):
1412
1697
  """Print debug message if debug mode is enabled."""
1698
+ hook_log(f"DEBUG {message}")
1413
1699
  if DEBUG:
1414
1700
  print(f"[COZELOOP_HOOK_DEBUG] {datetime.now().isoformat()} - {message}", file=sys.stderr)
1415
1701
 
@@ -1497,6 +1783,101 @@ def read_rollout_messages(transcript_path: str, start_line: int = 0) -> List[Dic
1497
1783
  return entries
1498
1784
 
1499
1785
 
1786
+ def _add_unique_path(paths: List[Path], p: Optional[Path]):
1787
+ if not p:
1788
+ return
1789
+ try:
1790
+ resolved = p.expanduser().resolve()
1791
+ except Exception:
1792
+ return
1793
+ if resolved not in paths:
1794
+ paths.append(resolved)
1795
+
1796
+
1797
+ def _candidate_codex_homes() -> List[Path]:
1798
+ homes: List[Path] = []
1799
+ _add_unique_path(homes, Path(os.environ["CODEX_HOME"]) if os.environ.get("CODEX_HOME") else None)
1800
+
1801
+ log_path = _log_file_path()
1802
+ if log_path:
1803
+ try:
1804
+ log_parent = Path(log_path).expanduser().resolve().parent
1805
+ if log_parent.name == "hooks":
1806
+ _add_unique_path(homes, log_parent.parent)
1807
+ except Exception:
1808
+ pass
1809
+
1810
+ _add_unique_path(homes, Path.home() / ".codex")
1811
+
1812
+ agents_root = Path.home() / ".coze" / "agents"
1813
+ try:
1814
+ if agents_root.exists():
1815
+ for child in agents_root.iterdir():
1816
+ _add_unique_path(homes, child / "codex-home")
1817
+ except Exception:
1818
+ pass
1819
+
1820
+ return homes
1821
+
1822
+
1823
+ def _latest_file(paths: List[Path]) -> Optional[Path]:
1824
+ existing = []
1825
+ for p in paths:
1826
+ try:
1827
+ if p.is_file():
1828
+ existing.append(p)
1829
+ except Exception:
1830
+ pass
1831
+ if not existing:
1832
+ return None
1833
+ return max(existing, key=lambda p: p.stat().st_mtime)
1834
+
1835
+
1836
+ def find_latest_transcript() -> Optional[str]:
1837
+ """Best-effort fallback when Codex does not pass hook stdin."""
1838
+ candidates: List[Path] = []
1839
+ for codex_home in _candidate_codex_homes():
1840
+ sessions_dir = codex_home / "sessions"
1841
+ if not sessions_dir.exists():
1842
+ continue
1843
+ try:
1844
+ candidates.extend(sessions_dir.rglob("rollout-*.jsonl"))
1845
+ except Exception as e:
1846
+ hook_log(f"fallback scan failed dir={sessions_dir} error={repr(e)}")
1847
+
1848
+ latest = _latest_file(candidates)
1849
+ if latest:
1850
+ return str(latest)
1851
+
1852
+ for codex_home in _candidate_codex_homes():
1853
+ sessions_dir = codex_home / "sessions"
1854
+ if not sessions_dir.exists():
1855
+ continue
1856
+ try:
1857
+ candidates.extend(sessions_dir.rglob("*.jsonl"))
1858
+ except Exception:
1859
+ pass
1860
+
1861
+ latest = _latest_file(candidates)
1862
+ return str(latest) if latest else None
1863
+
1864
+
1865
+ def recover_hook_input(reason: str) -> Optional[Dict[str, Any]]:
1866
+ transcript_path = find_latest_transcript()
1867
+ if not transcript_path:
1868
+ hook_log(f"fallback failed reason={reason} no transcript found")
1869
+ print(f"[CozeLoop] Hook input missing ({reason}); no Codex transcript found.", file=sys.stderr)
1870
+ return None
1871
+ hook_log(f"fallback transcript reason={reason} path={transcript_path}")
1872
+ print(f"[CozeLoop] Hook input missing ({reason}); fallback transcript: {transcript_path}", file=sys.stderr)
1873
+ return {
1874
+ "hook_event_name": "Stop",
1875
+ "session_id": "",
1876
+ "transcript_path": transcript_path,
1877
+ "input_fallback": reason,
1878
+ }
1879
+
1880
+
1500
1881
  def parse_session_meta(entries: List[Dict[str, Any]]) -> Dict[str, Any]:
1501
1882
  """Extract session identity from session_meta entry."""
1502
1883
  result = {
@@ -1552,6 +1933,8 @@ def is_real_user_message(payload: Dict[str, Any]) -> bool:
1552
1933
  if item.get("type") != "input_text":
1553
1934
  continue
1554
1935
  text = item.get("text", "")
1936
+ if parse_coze_context(text):
1937
+ return True
1555
1938
  if text.startswith("<environment_context>"):
1556
1939
  continue
1557
1940
  if text.startswith("<permissions instructions>"):
@@ -1570,11 +1953,12 @@ def extract_user_text(payload: Dict[str, Any]) -> str:
1570
1953
  for item in payload.get("content", []):
1571
1954
  if isinstance(item, dict) and item.get("type") == "input_text":
1572
1955
  text = item.get("text", "")
1573
- if (not text.startswith("<environment_context>") and
1956
+ if (parse_coze_context(text) or
1957
+ (not text.startswith("<environment_context>") and
1574
1958
  not text.startswith("<permissions instructions>") and
1575
- not text.startswith("<turn_aborted>")):
1959
+ not text.startswith("<turn_aborted>"))):
1576
1960
  parts.append(text)
1577
- return "\\n".join(parts)
1961
+ return "\n".join(parts)
1578
1962
 
1579
1963
 
1580
1964
  def extract_assistant_text(payload: Dict[str, Any]) -> str:
@@ -1583,7 +1967,7 @@ def extract_assistant_text(payload: Dict[str, Any]) -> str:
1583
1967
  for item in payload.get("content", []):
1584
1968
  if isinstance(item, dict) and item.get("type") in ("output_text", "text"):
1585
1969
  parts.append(item.get("text", ""))
1586
- return "\\n".join(parts)
1970
+ return "\n".join(parts)
1587
1971
 
1588
1972
 
1589
1973
  def extract_message_content_text(payload: Dict[str, Any]) -> str:
@@ -1595,7 +1979,7 @@ def extract_message_content_text(payload: Dict[str, Any]) -> str:
1595
1979
  text = item.get("text", "")
1596
1980
  if text:
1597
1981
  parts.append(text)
1598
- return "\\n".join(parts)
1982
+ return "\n".join(parts)
1599
1983
 
1600
1984
 
1601
1985
  def truncate_text(text: str, limit: int = 12000) -> str:
@@ -1607,6 +1991,93 @@ def truncate_text(text: str, limit: int = 12000) -> str:
1607
1991
 
1608
1992
  # --- Message Grouping ---
1609
1993
 
1994
+ def _parse_ts(obj):
1995
+ """从 codex entry/payload 的 timestamp 解析 datetime(带时区)。失败返回 None。
1996
+
1997
+ Codex rollout JSONL 每条 entry 顶层带 ISO8601 timestamp。建 span 时用它做
1998
+ start_time,避免回放时所有 span 挤在几毫秒内。
1999
+ """
2000
+ if not isinstance(obj, dict):
2001
+ return None
2002
+ ts = obj.get("timestamp") or obj.get("_ts")
2003
+ if not ts or not isinstance(ts, str):
2004
+ return None
2005
+ try:
2006
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
2007
+ except Exception:
2008
+ return None
2009
+
2010
+
2011
+ def _ts_ms(dt):
2012
+ """datetime → 毫秒时间戳(int);None → None。"""
2013
+ return int(dt.timestamp() * 1000) if dt is not None else None
2014
+
2015
+
2016
+ def _set_finish_time_safe(span, dt):
2017
+ """安全设置 span 结束时间。
2018
+
2019
+ set_finish_time 是 cozeloop SDK 0.1.25+ 的方法,云端旧版没有;缺失或异常都不能
2020
+ 让整条 trace 上报失败(real_*_ms tag 仍保留耗时信息)。
2021
+ """
2022
+ if dt is None:
2023
+ return
2024
+ fn = getattr(span, "set_finish_time", None)
2025
+ if fn is None:
2026
+ return
2027
+ try:
2028
+ fn(dt)
2029
+ except Exception:
2030
+ pass
2031
+
2032
+
2033
+ def _max_dt(*values):
2034
+ result = None
2035
+ for value in values:
2036
+ if value is not None and (result is None or value > result):
2037
+ result = value
2038
+ return result
2039
+
2040
+
2041
+ def _parse_ts_value(value):
2042
+ return _parse_ts({"_ts": value}) if value else None
2043
+
2044
+
2045
+ def _turn_timestamps(turn):
2046
+ values = []
2047
+ for item in [turn.get("user_message"), *turn.get("assistant_messages", [])]:
2048
+ dt = _parse_ts(item)
2049
+ if dt:
2050
+ values.append(dt)
2051
+ for item in [*turn.get("tool_calls", []), *turn.get("tool_results", [])]:
2052
+ dt = _parse_ts(item)
2053
+ if dt:
2054
+ values.append(dt)
2055
+ for sc in turn.get("subagent_calls", []):
2056
+ for key in ("_start_ts", "_end_ts"):
2057
+ dt = _parse_ts_value(sc.get(key))
2058
+ if dt:
2059
+ values.append(dt)
2060
+ return values
2061
+
2062
+
2063
+ def _turn_bounds(turn):
2064
+ values = _turn_timestamps(turn)
2065
+ if not values:
2066
+ return None, None
2067
+ return min(values), max(values)
2068
+
2069
+
2070
+ def _assistant_bounds(turn):
2071
+ values = []
2072
+ for item in turn.get("assistant_messages", []):
2073
+ dt = _parse_ts(item)
2074
+ if dt:
2075
+ values.append(dt)
2076
+ if not values:
2077
+ return None, None
2078
+ return min(values), max(values)
2079
+
2080
+
1610
2081
  def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
1611
2082
  """Group raw JSONL entries into conversation turns.
1612
2083
 
@@ -1629,6 +2100,9 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
1629
2100
  for entry in entries:
1630
2101
  entry_type = entry.get("type")
1631
2102
  payload = entry.get("payload", {})
2103
+ # 把 entry 顶层 timestamp 注入 payload,供后续建 span 取真实时间
2104
+ if isinstance(payload, dict) and entry.get("timestamp") and "_ts" not in payload:
2105
+ payload["_ts"] = entry.get("timestamp")
1632
2106
 
1633
2107
  # --- Turn lifecycle events ---
1634
2108
  if entry_type == "event_msg":
@@ -1713,6 +2187,8 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
1713
2187
  "message": args.get("message", ""),
1714
2188
  "model": args.get("model"),
1715
2189
  "result": None,
2190
+ "_start_ts": payload.get("_ts"),
2191
+ "_end_ts": None,
1716
2192
  }
1717
2193
  current_turn["subagent_calls"].append(subagent_call)
1718
2194
  pending_calls[call_id] = {"kind": "spawn", "subagent_call": subagent_call}
@@ -1720,14 +2196,16 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
1720
2196
  pending_calls[call_id] = {
1721
2197
  "kind": "wait",
1722
2198
  "ids": args.get("ids", []),
2199
+ "_start_ts": payload.get("_ts"),
1723
2200
  }
1724
2201
  else:
1725
2202
  current_turn["tool_calls"].append({
1726
2203
  "call_id": call_id,
1727
2204
  "name": name,
1728
2205
  "input": args,
2206
+ "_ts": payload.get("_ts"),
1729
2207
  })
1730
- pending_calls[call_id] = {"kind": "tool"}
2208
+ pending_calls[call_id] = {"kind": "tool", "_start_ts": payload.get("_ts")}
1731
2209
 
1732
2210
  elif item_type == "function_call_output":
1733
2211
  if current_turn is None:
@@ -1747,6 +2225,7 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
1747
2225
  subagent_call["nickname"] = out.get("nickname")
1748
2226
  except (json.JSONDecodeError, TypeError, AttributeError):
1749
2227
  pass
2228
+ subagent_call["_end_ts"] = payload.get("_ts")
1750
2229
 
1751
2230
  elif kind == "wait":
1752
2231
  try:
@@ -1759,6 +2238,7 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
1759
2238
  for sc in current_turn["subagent_calls"]:
1760
2239
  if sc.get("agent_id") == agent_id and sc.get("result") is None:
1761
2240
  sc["result"] = result_text
2241
+ sc["_end_ts"] = payload.get("_ts")
1762
2242
  break
1763
2243
  except (json.JSONDecodeError, TypeError, AttributeError):
1764
2244
  pass
@@ -1767,6 +2247,7 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
1767
2247
  current_turn["tool_results"].append({
1768
2248
  "call_id": call_id,
1769
2249
  "output": raw_output,
2250
+ "_ts": payload.get("_ts"),
1770
2251
  })
1771
2252
 
1772
2253
  if current_turn is not None:
@@ -1818,20 +2299,71 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1818
2299
  token = get_fresh_token()
1819
2300
  if token:
1820
2301
  os.environ["COZELOOP_API_TOKEN"] = token
1821
- client = cozeloop.new_client()
2302
+ hook_log(f"token resolved prefix={token[:12]}...")
2303
+ print(f"[CozeLoop] Token 获取成功 ({token[:12]}...)", file=sys.stderr)
2304
+ else:
2305
+ hook_log(
2306
+ "token missing "
2307
+ f"has_cozeloop_token={bool(os.environ.get('COZELOOP_API_TOKEN'))} "
2308
+ f"has_coze_token={bool(os.environ.get('COZE_API_TOKEN'))} "
2309
+ f"api_base_url={bool(get_api_base_url())}"
2310
+ )
2311
+ print("[CozeLoop] 警告: 未找到有效 Token,上报可能失败", file=sys.stderr)
2312
+ creds = _load_credentials()
2313
+ workspace_id = (creds or {}).get("workspace_id") or os.environ.get("COZELOOP_WORKSPACE_ID", "") or _DEFAULT_WORKSPACE_ID
2314
+ os.environ["COZELOOP_WORKSPACE_ID"] = workspace_id
2315
+ hook_log(f"workspace_id={workspace_id}")
2316
+ upload_events: List[str] = []
2317
+ client_kwargs = {
2318
+ "ultra_large_report": True,
2319
+ "upload_timeout": 120,
2320
+ "trace_finish_event_processor": _make_finish_event_processor(upload_events),
2321
+ }
2322
+ api_base_url = get_api_base_url()
2323
+ if api_base_url:
2324
+ client_kwargs["api_base_url"] = api_base_url
2325
+ hook_log(f"api_base_url={api_base_url}")
2326
+ if workspace_id:
2327
+ client_kwargs["workspace_id"] = workspace_id
2328
+ if token:
2329
+ client_kwargs["api_token"] = token
2330
+ client = cozeloop.new_client(**client_kwargs)
1822
2331
  ctx: List[Dict[str, Any]] = list(history_context) if history_context else []
1823
2332
 
1824
2333
  try:
1825
- with client.start_span(name="codex_request", span_type="main") as root_span:
2334
+ # 整体时间范围:所有 user/assistant payload 的真实 timestamp 极值
2335
+ _all_ts = []
2336
+ for _t in turns:
2337
+ _all_ts.extend(_turn_timestamps(_t))
2338
+ root_start_dt = min(_all_ts) if _all_ts else None
2339
+ root_end_dt = max(_all_ts) if _all_ts else None
2340
+
2341
+ with client.start_span(name="codex_request", span_type="main", start_time=root_start_dt) as root_span:
1826
2342
  root_span.set_runtime(Runtime(library="codex-cli"))
1827
- root_span.set_tags({
2343
+ root_tags = {
1828
2344
  "thread_id": session_id,
1829
2345
  "total_turns": len(turns),
1830
2346
  "source": "codex_cli",
1831
- })
1832
- root_span.set_baggage({
2347
+ }
2348
+ if root_start_dt is not None and root_end_dt is not None:
2349
+ _set_finish_time_safe(root_span, root_end_dt)
2350
+ root_tags["real_start_ms"] = _ts_ms(root_start_dt)
2351
+ root_tags["real_end_ms"] = _ts_ms(root_end_dt)
2352
+ root_tags["latency_ms"] = _ts_ms(root_end_dt) - _ts_ms(root_start_dt)
2353
+ root_baggage = {
1833
2354
  "thread_id": session_id,
1834
- })
2355
+ }
2356
+ # Inject coze-context kv (last occurrence across turns wins).
2357
+ coze_tags = {}
2358
+ for turn in turns:
2359
+ t = {f"coze_{k}": v for k, v in turn_coze_context(turn).items()}
2360
+ if t:
2361
+ coze_tags = t
2362
+ if coze_tags:
2363
+ root_tags.update(coze_tags)
2364
+ root_baggage.update(coze_tags)
2365
+ root_span.set_tags(root_tags)
2366
+ root_span.set_baggage(root_baggage)
1835
2367
 
1836
2368
  # Set root span input: all user messages
1837
2369
  root_input_parts = []
@@ -1840,7 +2372,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1840
2372
  if text:
1841
2373
  root_input_parts.append(text)
1842
2374
  if root_input_parts:
1843
- root_span.set_input(truncate_text("\\n\\n".join(root_input_parts)))
2375
+ root_span.set_input(truncate_text("\n\n".join(root_input_parts)))
1844
2376
 
1845
2377
  # Set root span output: all assistant messages
1846
2378
  root_output_parts = []
@@ -1850,25 +2382,47 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1850
2382
  if assistant_text:
1851
2383
  root_output_parts.append(assistant_text)
1852
2384
  if root_output_parts:
1853
- root_span.set_output(truncate_text("\\n\\n".join(root_output_parts)))
2385
+ root_span.set_output(truncate_text("\n\n".join(root_output_parts)))
1854
2386
 
1855
2387
  # Process each turn
1856
2388
  for i, turn in enumerate(turns):
1857
2389
  try:
1858
- with client.start_span(name=f"turn_{i}", span_type="main") as turn_span:
2390
+ # turn 真实时间:start=user payload 时间;end=最后一条 assistant payload 时间
2391
+ turn_start_dt, turn_end_dt = _turn_bounds(turn)
2392
+
2393
+ with client.start_span(name=f"turn_{i}", span_type="main", start_time=turn_start_dt) as turn_span:
1859
2394
  turn_span.set_runtime(Runtime(library="codex-cli"))
1860
- turn_span.set_tags({
2395
+ _turn_tags = {
1861
2396
  "thread_id": session_id,
1862
2397
  "turn_index": i,
1863
2398
  "turn_id": turn.get("turn_id", ""),
1864
2399
  "source": "codex_cli",
1865
- })
2400
+ }
2401
+ if turn_start_dt is not None and turn_end_dt is not None:
2402
+ _set_finish_time_safe(turn_span, turn_end_dt)
2403
+ _turn_tags["real_start_ms"] = _ts_ms(turn_start_dt)
2404
+ _turn_tags["real_end_ms"] = _ts_ms(turn_end_dt)
2405
+ _turn_tags["latency_ms"] = _ts_ms(turn_end_dt) - _ts_ms(turn_start_dt)
2406
+ turn_span.set_tags(_turn_tags)
1866
2407
 
1867
2408
  # --- Model span for assistant response ---
1868
2409
  if turn.get("assistant_messages"):
1869
- with client.start_span(name="assistant_response", span_type="model") as model_span:
2410
+ # model span start:第一条 assistant payload 时间,回退到 turn 起点
2411
+ _model_start_dt, _model_end_dt = _assistant_bounds(turn)
2412
+ if _model_start_dt is None:
2413
+ _model_start_dt = turn_start_dt
2414
+ if _model_end_dt is None:
2415
+ _model_end_dt = turn_end_dt
2416
+ with client.start_span(name="assistant_response", span_type="model", start_time=_model_start_dt) as model_span:
1870
2417
  model_span.set_runtime(Runtime(library="codex-cli"))
1871
2418
  model_span.set_model_name(model_name)
2419
+ if _model_start_dt is not None and _model_end_dt is not None:
2420
+ _set_finish_time_safe(model_span, _model_end_dt)
2421
+ model_span.set_tags({
2422
+ "real_start_ms": _ts_ms(_model_start_dt),
2423
+ "real_end_ms": _ts_ms(_model_end_dt),
2424
+ "latency_ms": _ts_ms(_model_end_dt) - _ts_ms(_model_start_dt),
2425
+ })
1872
2426
 
1873
2427
  # Build input messages: history + current turn input
1874
2428
  turn_input = turn.get("input_messages", [])
@@ -1936,17 +2490,30 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1936
2490
  # --- Tool call spans ---
1937
2491
  for tool_call in turn.get("tool_calls", []):
1938
2492
  tool_name = tool_call.get("name", "unknown")
1939
- with client.start_span(name=f"tool_{tool_name}", span_type="tool") as tool_span:
2493
+ tool_start_dt = _parse_ts(tool_call) or turn_start_dt
2494
+ tool_end_dt = None
2495
+ call_id = tool_call.get("call_id")
2496
+ for result in turn.get("tool_results", []):
2497
+ if result.get("call_id") == call_id:
2498
+ tool_end_dt = _parse_ts(result)
2499
+ break
2500
+ tool_finish_dt = _max_dt(tool_end_dt, tool_start_dt)
2501
+ with client.start_span(name=f"tool_{tool_name}", span_type="tool", start_time=tool_start_dt) as tool_span:
1940
2502
  tool_span.set_runtime(Runtime(library="codex-cli"))
1941
- tool_span.set_tags({
2503
+ tool_tags = {
1942
2504
  "tool_name": tool_name,
1943
- "call_id": tool_call.get("call_id"),
1944
- })
2505
+ "call_id": call_id,
2506
+ }
2507
+ if tool_start_dt is not None and tool_finish_dt is not None:
2508
+ _set_finish_time_safe(tool_span, tool_finish_dt)
2509
+ tool_tags["real_start_ms"] = _ts_ms(tool_start_dt)
2510
+ tool_tags["real_end_ms"] = _ts_ms(tool_finish_dt)
2511
+ tool_tags["latency_ms"] = _ts_ms(tool_finish_dt) - _ts_ms(tool_start_dt)
2512
+ tool_span.set_tags(tool_tags)
1945
2513
  tool_span.set_input(
1946
2514
  json.dumps(tool_call.get("input", {}), ensure_ascii=False)[:2000]
1947
2515
  )
1948
2516
  # Find matching tool result
1949
- call_id = tool_call.get("call_id")
1950
2517
  for result in turn.get("tool_results", []):
1951
2518
  if result.get("call_id") == call_id:
1952
2519
  output = result.get("output", "")
@@ -1959,15 +2526,24 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1959
2526
  for sc in turn.get("subagent_calls", []):
1960
2527
  agent_id = sc.get("agent_id") or "unknown"
1961
2528
  nickname = sc.get("nickname") or agent_id
2529
+ subagent_start_dt = _parse_ts_value(sc.get("_start_ts")) or turn_start_dt
2530
+ subagent_end_dt = _parse_ts_value(sc.get("_end_ts")) or turn_end_dt
2531
+ subagent_finish_dt = _max_dt(subagent_end_dt, subagent_start_dt)
1962
2532
 
1963
- with client.start_span(name=f"subagent_{nickname}", span_type="agent") as subagent_span:
2533
+ with client.start_span(name=f"subagent_{nickname}", span_type="agent", start_time=subagent_start_dt) as subagent_span:
1964
2534
  subagent_span.set_runtime(Runtime(library="codex-cli"))
1965
- subagent_span.set_tags({
2535
+ subagent_tags = {
1966
2536
  "agent_id": agent_id,
1967
2537
  "agent_nickname": nickname,
1968
2538
  "agent_role": sc.get("role") or "",
1969
2539
  "agent_model": sc.get("model") or "",
1970
- })
2540
+ }
2541
+ if subagent_start_dt is not None and subagent_finish_dt is not None:
2542
+ _set_finish_time_safe(subagent_span, subagent_finish_dt)
2543
+ subagent_tags["real_start_ms"] = _ts_ms(subagent_start_dt)
2544
+ subagent_tags["real_end_ms"] = _ts_ms(subagent_finish_dt)
2545
+ subagent_tags["latency_ms"] = _ts_ms(subagent_finish_dt) - _ts_ms(subagent_start_dt)
2546
+ subagent_span.set_tags(subagent_tags)
1971
2547
  subagent_span.set_input(sc.get("message", "")[:2000])
1972
2548
 
1973
2549
  # Load and include saved subagent turn data
@@ -1977,20 +2553,45 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1977
2553
  sa_model = sa_data.get("model_name", "codex")
1978
2554
 
1979
2555
  for si, sa_turn in enumerate(sa_turns):
1980
- with client.start_span(name=f"turn_{si}", span_type="main") as sa_turn_span:
2556
+ sa_turn_start_dt, sa_turn_end_dt = _turn_bounds(sa_turn)
2557
+ if sa_turn_start_dt is None:
2558
+ sa_turn_start_dt = subagent_start_dt
2559
+ if sa_turn_end_dt is None:
2560
+ sa_turn_end_dt = sa_turn_start_dt
2561
+ sa_turn_finish_dt = _max_dt(sa_turn_end_dt, sa_turn_start_dt)
2562
+
2563
+ with client.start_span(name=f"turn_{si}", span_type="main", start_time=sa_turn_start_dt) as sa_turn_span:
1981
2564
  sa_turn_span.set_runtime(Runtime(library="codex-cli"))
1982
- sa_turn_span.set_tags({
2565
+ sa_turn_tags = {
1983
2566
  "turn_index": si,
1984
2567
  "turn_id": sa_turn.get("turn_id", ""),
1985
2568
  "agent_name": nickname,
1986
- })
2569
+ }
2570
+ if sa_turn_start_dt is not None and sa_turn_finish_dt is not None:
2571
+ _set_finish_time_safe(sa_turn_span, sa_turn_finish_dt)
2572
+ sa_turn_tags["real_start_ms"] = _ts_ms(sa_turn_start_dt)
2573
+ sa_turn_tags["real_end_ms"] = _ts_ms(sa_turn_finish_dt)
2574
+ sa_turn_tags["latency_ms"] = _ts_ms(sa_turn_finish_dt) - _ts_ms(sa_turn_start_dt)
2575
+ sa_turn_span.set_tags(sa_turn_tags)
1987
2576
 
1988
2577
  # Subagent model span
1989
2578
  if sa_turn.get("assistant_messages"):
1990
- with client.start_span(name="assistant_response", span_type="model") as sa_model_span:
2579
+ sa_model_start_dt, sa_model_end_dt = _assistant_bounds(sa_turn)
2580
+ if sa_model_start_dt is None:
2581
+ sa_model_start_dt = sa_turn_start_dt
2582
+ if sa_model_end_dt is None:
2583
+ sa_model_end_dt = sa_turn_end_dt
2584
+ sa_model_finish_dt = _max_dt(sa_model_end_dt, sa_model_start_dt)
2585
+ with client.start_span(name="assistant_response", span_type="model", start_time=sa_model_start_dt) as sa_model_span:
1991
2586
  sa_model_span.set_runtime(Runtime(library="codex-cli"))
1992
2587
  sa_model_span.set_model_name(sa_model)
1993
- sa_model_span.set_tags({"agent_name": nickname})
2588
+ sa_model_tags = {"agent_name": nickname}
2589
+ if sa_model_start_dt is not None and sa_model_finish_dt is not None:
2590
+ _set_finish_time_safe(sa_model_span, sa_model_finish_dt)
2591
+ sa_model_tags["real_start_ms"] = _ts_ms(sa_model_start_dt)
2592
+ sa_model_tags["real_end_ms"] = _ts_ms(sa_model_finish_dt)
2593
+ sa_model_tags["latency_ms"] = _ts_ms(sa_model_finish_dt) - _ts_ms(sa_model_start_dt)
2594
+ sa_model_span.set_tags(sa_model_tags)
1994
2595
 
1995
2596
  sa_input = sa_turn.get("input_messages", [])
1996
2597
  if not sa_input:
@@ -2034,17 +2635,30 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
2034
2635
  # Subagent tool spans
2035
2636
  for sa_tc in sa_turn.get("tool_calls", []):
2036
2637
  sa_tool_name = sa_tc.get("name", "unknown")
2037
- with client.start_span(name=f"tool_{sa_tool_name}", span_type="tool") as sa_tool_span:
2638
+ sa_tool_start_dt = _parse_ts(sa_tc) or sa_turn_start_dt
2639
+ sa_tool_end_dt = None
2640
+ sa_cid = sa_tc.get("call_id")
2641
+ for sa_r in sa_turn.get("tool_results", []):
2642
+ if sa_r.get("call_id") == sa_cid:
2643
+ sa_tool_end_dt = _parse_ts(sa_r)
2644
+ break
2645
+ sa_tool_finish_dt = _max_dt(sa_tool_end_dt, sa_tool_start_dt)
2646
+ with client.start_span(name=f"tool_{sa_tool_name}", span_type="tool", start_time=sa_tool_start_dt) as sa_tool_span:
2038
2647
  sa_tool_span.set_runtime(Runtime(library="codex-cli"))
2039
- sa_tool_span.set_tags({
2648
+ sa_tool_tags = {
2040
2649
  "tool_name": sa_tool_name,
2041
- "call_id": sa_tc.get("call_id"),
2650
+ "call_id": sa_cid,
2042
2651
  "agent_name": nickname,
2043
- })
2652
+ }
2653
+ if sa_tool_start_dt is not None and sa_tool_finish_dt is not None:
2654
+ _set_finish_time_safe(sa_tool_span, sa_tool_finish_dt)
2655
+ sa_tool_tags["real_start_ms"] = _ts_ms(sa_tool_start_dt)
2656
+ sa_tool_tags["real_end_ms"] = _ts_ms(sa_tool_finish_dt)
2657
+ sa_tool_tags["latency_ms"] = _ts_ms(sa_tool_finish_dt) - _ts_ms(sa_tool_start_dt)
2658
+ sa_tool_span.set_tags(sa_tool_tags)
2044
2659
  sa_tool_span.set_input(
2045
2660
  json.dumps(sa_tc.get("input", {}), ensure_ascii=False)[:2000]
2046
2661
  )
2047
- sa_cid = sa_tc.get("call_id")
2048
2662
  for sa_r in sa_turn.get("tool_results", []):
2049
2663
  if sa_r.get("call_id") == sa_cid:
2050
2664
  sa_out = sa_r.get("output", "")
@@ -2074,15 +2688,22 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
2074
2688
  debug_log(f"Error processing turn {i}: {e}")
2075
2689
  continue
2076
2690
 
2691
+ hook_log(f"processed turns={len(turns)} session_id={session_id}")
2077
2692
  debug_log(f"Successfully processed {len(turns)} turn(s) for session {session_id}")
2078
2693
 
2079
2694
  except Exception as e:
2695
+ hook_log(f"send exception={repr(e)}")
2080
2696
  debug_log(f"An error occurred while sending traces to CozeLoop: {e}")
2081
2697
  return None
2082
2698
  finally:
2083
2699
  client.close()
2700
+ hook_log("client closed")
2084
2701
  debug_log("CozeLoop client closed.")
2085
2702
 
2703
+ if upload_events:
2704
+ hook_log(f"upload failed state not advanced failures={len(upload_events)} detail={upload_events[-1][:500]}")
2705
+ return None
2706
+
2086
2707
  return ctx
2087
2708
 
2088
2709
 
@@ -2090,10 +2711,13 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
2090
2711
 
2091
2712
  def main():
2092
2713
  """Main entry point for the Codex CozeLoop hook."""
2714
+ print("[CozeLoop] Hook triggered (Codex).", file=sys.stderr)
2715
+ hook_log("hook triggered")
2093
2716
  debug_log("Codex CozeLoop hook started.")
2094
2717
 
2095
2718
  # Check if tracing is enabled
2096
2719
  if os.environ.get("TRACE_TO_COZELOOP", "").lower() == "false":
2720
+ hook_log("skip trace disabled")
2097
2721
  debug_log("TRACE_TO_COZELOOP is set to 'false', skipping")
2098
2722
  return
2099
2723
 
@@ -2101,24 +2725,41 @@ def main():
2101
2725
  try:
2102
2726
  raw_input = sys.stdin.read().strip()
2103
2727
  if not raw_input:
2728
+ hook_log("stdin empty, trying fallback")
2104
2729
  debug_log("No input received from stdin")
2105
- return
2106
- hook_input = json.loads(raw_input)
2730
+ hook_input = recover_hook_input("empty_stdin")
2731
+ if not hook_input:
2732
+ return
2733
+ else:
2734
+ hook_input = json.loads(raw_input)
2107
2735
  except Exception as e:
2736
+ hook_log(f"stdin parse error={repr(e)}, trying fallback")
2108
2737
  debug_log(f"Error reading hook input from stdin: {e}")
2109
- return
2738
+ hook_input = recover_hook_input("stdin_parse_error")
2739
+ if not hook_input:
2740
+ return
2110
2741
 
2111
2742
  debug_log(f"Hook input: {json.dumps(hook_input, ensure_ascii=False)}")
2112
2743
 
2113
2744
  # Get transcript path
2114
2745
  transcript_path = hook_input.get("transcript_path")
2115
2746
  if not transcript_path:
2747
+ hook_log("missing transcript_path, trying fallback")
2116
2748
  debug_log("No transcript_path in hook input")
2117
- return
2749
+ recovered = recover_hook_input("missing_transcript_path")
2750
+ if not recovered:
2751
+ return
2752
+ hook_input = recovered
2753
+ transcript_path = hook_input.get("transcript_path")
2118
2754
 
2119
2755
  if not os.path.exists(transcript_path):
2756
+ hook_log(f"transcript not found path={transcript_path}, trying fallback")
2120
2757
  debug_log(f"Transcript file not found: {transcript_path}")
2121
- return
2758
+ recovered = recover_hook_input("transcript_not_found")
2759
+ if not recovered:
2760
+ return
2761
+ hook_input = recovered
2762
+ transcript_path = hook_input.get("transcript_path")
2122
2763
 
2123
2764
  # Load state
2124
2765
  state_file = get_state_file_path(transcript_path)
@@ -2128,9 +2769,11 @@ def main():
2128
2769
  entries = read_rollout_messages(transcript_path, state["last_processed_line"])
2129
2770
 
2130
2771
  if not entries:
2772
+ hook_log(f"skip no new entries transcript={transcript_path}")
2131
2773
  debug_log("No new entries to process")
2132
2774
  return
2133
2775
 
2776
+ hook_log(f"read entries={len(entries)} from_line={state['last_processed_line']} transcript={transcript_path}")
2134
2777
  debug_log(f"Read {len(entries)} new entries from line {state['last_processed_line']}")
2135
2778
 
2136
2779
  # Parse session identity
@@ -2180,11 +2823,20 @@ def main():
2180
2823
  last_line = max(e.get("_line_number", 0) for e in entries) + 1
2181
2824
  state["last_processed_line"] = last_line
2182
2825
  save_state(state_file, state)
2826
+ hook_log(f"subagent saved session_id={session_id} turns={len(turns[-1:])} last_line={last_line}")
2183
2827
  debug_log("Subagent data saved, hook completed")
2184
2828
  return
2185
2829
 
2186
- # Send turns to CozeLoop
2830
+ # Send turns to CozeLoop — only if at least one turn carries coze-context.
2187
2831
  if turns:
2832
+ has_coze_ctx = any(
2833
+ turn_coze_context(t)
2834
+ for t in turns
2835
+ )
2836
+ if not has_coze_ctx:
2837
+ hook_log(f"skip no coze-context turns={len(turns)} session_id={session_id}")
2838
+ debug_log("No coze-context found in any turn, skipping upload.")
2839
+ return
2188
2840
  history_context = state.get("conversation_history", [])
2189
2841
  updated_history = send_turns_to_cozeloop(
2190
2842
  turns, session_id, model_name,
@@ -2195,12 +2847,16 @@ def main():
2195
2847
  state["last_processed_line"] = last_line
2196
2848
  state["conversation_history"] = updated_history
2197
2849
  save_state(state_file, state)
2850
+ hook_log(f"state advanced last_line={last_line} session_id={session_id}")
2198
2851
  debug_log(f"State updated, last processed line: {last_line}")
2199
2852
  else:
2853
+ hook_log(f"send failed state not advanced session_id={session_id}")
2200
2854
  debug_log("Send failed, state not advanced")
2201
2855
  else:
2856
+ hook_log(f"skip no turns session_id={session_id}")
2202
2857
  debug_log("No turns to send")
2203
2858
 
2859
+ hook_log("hook completed")
2204
2860
  debug_log("Codex CozeLoop hook completed.")
2205
2861
 
2206
2862
 
@@ -3514,10 +4170,12 @@ async function _refreshToken(refreshTok) {
3514
4170
  try {
3515
4171
  const d = JSON.parse(buf);
3516
4172
  if (d.access_token) {
4173
+ const existing = _loadCreds() ?? {};
3517
4174
  const creds = {
3518
4175
  access_token: d.access_token,
3519
4176
  refresh_token: d.refresh_token ?? refreshTok,
3520
4177
  expires_at: (d.expires_in ?? 0) * 1000, // unix timestamp in seconds
4178
+ workspace_id: existing.workspace_id ?? "",
3521
4179
  };
3522
4180
  _saveCreds(creds);
3523
4181
  resolve(creds.access_token);
@@ -3541,7 +4199,7 @@ async function getRefreshedToken(currentAuthorization) {
3541
4199
  const newToken = await _refreshToken(creds.refresh_token);
3542
4200
  if (newToken) return \`Bearer \${newToken}\`;
3543
4201
  }
3544
- return currentAuthorization; // fallback
4202
+ return null;
3545
4203
  }
3546
4204
  // ─────────────────────────────────────────────────────────────────────────
3547
4205
 
@@ -3613,6 +4271,9 @@ export class CozeloopExporter {
3613
4271
  this.provider = null;
3614
4272
  this.tracer = null;
3615
4273
  }
4274
+ } else if (fresh === null) {
4275
+ this.api.logger.error("[CozeloopTrace] Local credentials exist but token refresh failed; refusing to reuse stale authorization.");
4276
+ this.config.authorization = "";
3616
4277
  }
3617
4278
  }
3618
4279
  async ensureInitialized() {
@@ -4126,13 +4787,19 @@ def refresh(rt):
4126
4787
  try:
4127
4788
  body = json.dumps({"grant_type":"refresh_token","client_id":CLIENT_ID,"refresh_token":rt}).encode()
4128
4789
  req = urllib.request.Request(f"{COZE_API}/api/permission/oauth2/token",
4129
- data=body, headers={"Content-Type":"application/json"})
4790
+ data=body, headers={
4791
+ "Content-Type":"application/json",
4792
+ "x-tt-env":"ppe_cozelab",
4793
+ "x-use-ppe":"1",
4794
+ })
4130
4795
  with urllib.request.urlopen(req, timeout=10) as r:
4131
4796
  d = json.loads(r.read())
4132
4797
  if d.get("access_token"):
4798
+ existing = load() or {}
4133
4799
  save({"access_token":d["access_token"],
4134
4800
  "refresh_token":d.get("refresh_token",rt),
4135
- "expires_at":d.get("expires_in",0)*1000})
4801
+ "expires_at":d.get("expires_in",0)*1000,
4802
+ "workspace_id":existing.get("workspace_id","")})
4136
4803
  return True
4137
4804
  except Exception as e:
4138
4805
  print(f"[cozeloop_refresh] refresh failed: {e}", file=sys.stderr)
@@ -4164,6 +4831,7 @@ function parseArgs() {
4164
4831
  if (arg === '--refresh') { args['refresh'] = true; continue; }
4165
4832
  if (arg === '--verify') { args['verify'] = true; continue; }
4166
4833
  if (arg === '--cloud') { args['cloud'] = true; continue; }
4834
+ if (arg === '--force') { args['force'] = true; continue; }
4167
4835
  const m = arg.match(/^--([^=]+)=(.+)$/);
4168
4836
  if (m) args[m[1]] = m[2];
4169
4837
  }
@@ -4225,6 +4893,7 @@ function validateArgs(args) {
4225
4893
  'codex-home': args['codex-home'],
4226
4894
  pairCode: args['pair-code'],
4227
4895
  cloud: true,
4896
+ force: !!args['force'],
4228
4897
  };
4229
4898
  }
4230
4899
  // config.json 缺失:回退到显式 --agent
@@ -4243,6 +4912,7 @@ function validateArgs(args) {
4243
4912
  'codex-home': args['codex-home'],
4244
4913
  pairCode: args['pair-code'],
4245
4914
  cloud: true,
4915
+ force: !!args['force'],
4246
4916
  };
4247
4917
  }
4248
4918
 
@@ -4257,6 +4927,7 @@ function validateArgs(args) {
4257
4927
  'codex-home': args['codex-home'],
4258
4928
  pairCode: args['pair-code'],
4259
4929
  cloud: !!args['cloud'],
4930
+ force: !!args['force'],
4260
4931
  };
4261
4932
  }
4262
4933
 
@@ -4268,6 +4939,9 @@ function validateArgs(args) {
4268
4939
  ' --agent=claude-code | codex | openclaw (全局配置)',
4269
4940
  ' --agent-id=<id> (按 ~/.coze/agents/<id> 的 framework 自动路由)',
4270
4941
  '',
4942
+ 'Flags:',
4943
+ ' --force 强制重装(OpenClaw 跳过幂等检查,无条件重写插件 + 重装依赖 + 重启 gateway)',
4944
+ '',
4271
4945
  'Other commands:',
4272
4946
  ' --status Show authorization status',
4273
4947
  ' --login Login (Device Code flow)',
@@ -4287,7 +4961,7 @@ function validateArgs(args) {
4287
4961
  ' --agent=openclaw',
4288
4962
  ]);
4289
4963
  }
4290
- return { agent: args['agent'], 'codex-home': args['codex-home'], pairCode: args['pair-code'], cloud: !!args['cloud'] };
4964
+ return { agent: args['agent'], 'codex-home': args['codex-home'], pairCode: args['pair-code'], cloud: !!args['cloud'], force: !!args['force'] };
4291
4965
  }
4292
4966
 
4293
4967
  // ─── 4. Agent detection ──────────────────────────────────────────────────────
@@ -4378,28 +5052,34 @@ function checkPython() {
4378
5052
  return pythonCmd;
4379
5053
  }
4380
5054
 
4381
- // Verify the `cozeloop` Python SDK is importable; auto-install if missing.
4382
- // Claude Code / Codex hooks `import cozeloop` at runtime without it the
4383
- // first Stop hook fails with "cozeloop SDK not found" long after onboarding.
5055
+ // Verify the `cozeloop` Python SDK is importable AND new enough; install/upgrade otherwise.
5056
+ // Claude Code / Codex hooks `import cozeloop` at runtime and call set_finish_time(0.1.25+)。
5057
+ // 旧版(<=0.1.24)能 import 但缺该方法,会让 hook 上报整条 trace 失败,所以这里用能力探测
5058
+ // (hasattr set_finish_time)而非单纯 import,并在不达标时升级到带下限的版本。
5059
+ // 注意:这是安装阶段的 python,可能不是跑 hook 的那个 python(已知坑),所以它只是双保险——
5060
+ // 真正的运行时拦截在 hook 脚本的 _ensure_cozeloop_sdk 里。
5061
+ const COZELOOP_MIN_SPEC = 'cozeloop>=0.1.28';
5062
+ // 探测脚本:import 成功且具备 set_finish_time 能力 → exit 0;否则非 0。
5063
+ const COZELOOP_CAPABLE_PROBE = `import cozeloop,sys; sys.exit(0 if hasattr(cozeloop.Span,'set_finish_time') else 3)`;
4384
5064
  function checkCozeloopSdk(pythonCmd) {
4385
5065
  try {
4386
- execSync(`${pythonCmd} -c "import cozeloop"`, { stdio: 'pipe' });
5066
+ execSync(`${pythonCmd} -c "${COZELOOP_CAPABLE_PROBE}"`, { stdio: 'pipe' });
4387
5067
  ok('cozeloop SDK — OK');
4388
5068
  return;
4389
- } catch { /* not installed try to install below */ }
5069
+ } catch { /* 未装或版本过旧下面安装/升级 */ }
4390
5070
 
4391
- info('cozeloop SDK 未安装,正在安装 (pip install cozeloop)...');
5071
+ info(`cozeloop SDK 不可用或版本过旧,正在安装/升级 (pip install -U '${COZELOOP_MIN_SPEC}')...`);
4392
5072
  try {
4393
- execSync(`${pythonCmd} -m pip install --quiet --upgrade cozeloop`, { stdio: 'pipe' });
4394
- // Confirm it imports now
4395
- execSync(`${pythonCmd} -c "import cozeloop"`, { stdio: 'pipe' });
4396
- ok('cozeloop SDK 安装成功');
5073
+ execSync(`${pythonCmd} -m pip install --quiet --upgrade '${COZELOOP_MIN_SPEC}'`, { stdio: 'pipe' });
5074
+ // Confirm it imports and is capable now
5075
+ execSync(`${pythonCmd} -c "${COZELOOP_CAPABLE_PROBE}"`, { stdio: 'pipe' });
5076
+ ok('cozeloop SDK 安装/升级成功');
4397
5077
  } catch (e) {
4398
5078
  warnBox([
4399
- '⚠ WARNING: 无法自动安装 cozeloop SDK',
5079
+ '⚠ WARNING: 无法自动安装/升级 cozeloop SDK 到所需版本',
4400
5080
  '',
4401
5081
  '请手动安装后再使用,否则 hook 触发时 trace 上报会失败:',
4402
- ` ${pythonCmd} -m pip install cozeloop`,
5082
+ ` ${pythonCmd} -m pip install -U '${COZELOOP_MIN_SPEC}'`,
4403
5083
  '',
4404
5084
  (e.stderr ? e.stderr.toString().trim() : e.message),
4405
5085
  ]);
@@ -4544,6 +5224,7 @@ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd, configBaseDir, cl
4544
5224
  if (cloud) {
4545
5225
  const loopToken = readEnv('COZELOOP_API_TOKEN');
4546
5226
  const cozeToken = readEnv('COZE_API_TOKEN');
5227
+ existing.env.COZELAB_ONBOARD_CLOUD = '1';
4547
5228
  if (loopToken) {
4548
5229
  existing.env.COZELOOP_API_TOKEN = loopToken;
4549
5230
  delete existing.env.COZE_API_TOKEN;
@@ -4552,8 +5233,9 @@ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd, configBaseDir, cl
4552
5233
  delete existing.env.COZELOOP_API_TOKEN;
4553
5234
  }
4554
5235
  } else {
4555
- existing.env.COZELOOP_API_TOKEN = patToken;
5236
+ delete existing.env.COZELOOP_API_TOKEN;
4556
5237
  delete existing.env.COZE_API_TOKEN;
5238
+ delete existing.env.COZELAB_ONBOARD_CLOUD;
4557
5239
  }
4558
5240
  const loopBaseUrl = readEnv('COZELOOP_API_BASE_URL');
4559
5241
  const otelEndpoint = readEnv('OTEL_ENDPOINT');
@@ -4582,8 +5264,8 @@ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd, configBaseDir, cl
4582
5264
 
4583
5265
  // writeCodexHook 把 hook 写进指定的 CODEX_HOME。codexHome 缺省 ~/.codex;
4584
5266
  // 传入动态目录(如 coze-bridge 的 /tmp/coze-bridge-codex-home-xxx)即可 per-agent 生效。
4585
- // cloud=true cozeloop.env 不写死 token —— 云端 hook 运行时直接读环境变量
4586
- // COZE_API_TOKEN(见 scripts/codex/cozeloop_hook.py get_fresh_token)。
5267
+ // 本地模式不把短期 token 写入 cozeloop.env;Hook 运行时读取 ~/.cozeloop/credentials.json。
5268
+ // cloud=true 时写 COZELAB_ONBOARD_CLOUD,并带入 sandbox 注入的 trace token。
4587
5269
  function writeCodexHook(token, workspaceId, pythonCmd, codexHome, cloud) {
4588
5270
  const home = codexHome || path.join(os.homedir(), '.codex');
4589
5271
  const hooksDir = path.join(home, 'hooks');
@@ -4606,13 +5288,12 @@ function writeCodexHook(token, workspaceId, pythonCmd, codexHome, cloud) {
4606
5288
  if (cloud) {
4607
5289
  const loopToken = readEnv('COZELOOP_API_TOKEN');
4608
5290
  const cozeToken = readEnv('COZE_API_TOKEN');
5291
+ envLines.push(shellEnvLine('COZELAB_ONBOARD_CLOUD', '1'));
4609
5292
  if (loopToken) {
4610
5293
  envLines.push(shellEnvLine('COZELOOP_API_TOKEN', loopToken));
4611
5294
  } else if (cozeToken) {
4612
5295
  envLines.push(shellEnvLine('COZE_API_TOKEN', cozeToken));
4613
5296
  }
4614
- } else {
4615
- envLines.push(shellEnvLine('COZELOOP_API_TOKEN', token));
4616
5297
  }
4617
5298
  envLines.push(shellEnvLine('CODEX_HOME', home));
4618
5299
  envLines.push(shellEnvLine('COZELOOP_HOOK_LOG', logFile));
@@ -4722,7 +5403,7 @@ function normalizeCozeloopApiBaseUrl(raw) {
4722
5403
  return base.slice(0, -'/v1/loop/opentelemetry'.length).replace(/\/+$/, '');
4723
5404
  }
4724
5405
  if (base.endsWith('/api/v1/loop/opentelemetry')) {
4725
- return base.slice(0, -'/loop/opentelemetry'.length).replace(/\/+$/, '');
5406
+ return base.slice(0, -'/v1/loop/opentelemetry'.length).replace(/\/+$/, '');
4726
5407
  }
4727
5408
  if (base.endsWith('/api/v1')) {
4728
5409
  return base.slice(0, -'/v1'.length).replace(/\/+$/, '');
@@ -4772,6 +5453,12 @@ function applyOpenClawPluginConfig(existing, token, workspaceId, agentId, cloud)
4772
5453
  pcfg.endpoint = getOtelEndpointBase(cloud);
4773
5454
  pcfg.workspaceId = workspaceId;
4774
5455
  pcfg.debug = true;
5456
+ pcfg.disableLocalCredentials = !!cloud;
5457
+ // 插件代码版本:参与幂等比对,升级插件(bump scripts/openclaw/package.json version)后
5458
+ // 强制触发重写+重装+重启,避免云端 pluginDir 滞留旧插件 dist。
5459
+ if (OPENCLAW_PLUGIN_VERSION) {
5460
+ pcfg.pluginVersion = OPENCLAW_PLUGIN_VERSION;
5461
+ }
4775
5462
  // per-agent trace 放行:把当前 agentId 并入 traceAgentIds(去重、归一为小写,
4776
5463
  // 与插件侧 resolveAgentIdFromHookCtx 的归一一致)。无 agentId(全局模式)则不动
4777
5464
  // allowlist —— 空 allowlist 表示全部放行。
@@ -5132,6 +5819,117 @@ async function verifyTraceReport(token, workspaceId, pairCode, tracesUrl) {
5132
5819
  return { success, status: res.status, body: res.body, traceId, pairCode: pair };
5133
5820
  }
5134
5821
 
5822
+ // ── OpenClaw 专属上报链路校验 ──────────────────────────────────────────────
5823
+ // 为什么单独一条:claude-code/codex 的 verify 用主流程刚 getValidToken() 刷新过的有效
5824
+ // token 直发,而 openclaw 运行时上报用的是【写死在 openclaw.json 插件 config.authorization
5825
+ // 里的静态 token】。两者是不同 token —— 插件那个失效(401/4100)时通用 verify 照样 ok,
5826
+ // 这就是“verify=ok 但实际查不到 trace”假象的根因。本函数改为读插件实际配置的 token 打
5827
+ // ingest,真实反映运行时会不会 401。
5828
+ //
5829
+ // cloud/local 兼容:插件配置位置都在 resolveHomeDir(cloud)/.openclaw/openclaw.json,
5830
+ // endpoint 都走 getOtelEndpointBase(cloud),逻辑统一。差异在 token 刷新:
5831
+ // - local:disableLocalCredentials=false,插件会读 ~/.cozeloop/credentials.json 自动刷新,
5832
+ // 所以额外检测【实际加载的插件是否含刷新逻辑 getRefreshedToken】,无则告警。
5833
+ // - cloud:disableLocalCredentials=true,插件只用写死的 token、不刷新,token 失效需重注入,
5834
+ // 刷新能力检测对 cloud 无意义(跳过)。
5835
+ async function verifyOpenClawTraceLink(cloud) {
5836
+ const home = resolveHomeDir(cloud);
5837
+ const configPath = path.join(home, '.openclaw', 'openclaw.json');
5838
+ let pcfg = null;
5839
+ try {
5840
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
5841
+ pcfg = cfg?.plugins?.entries?.['openclaw-cozeloop-trace']?.config || null;
5842
+ } catch (e) {
5843
+ warn(`无法读取 openclaw.json 插件配置: ${e.message}`);
5844
+ return { success: false, status: 0, body: `read ${configPath} failed: ${e.message}` };
5845
+ }
5846
+ if (!pcfg || !pcfg.authorization) {
5847
+ warn('openclaw 插件未配置 authorization,无法校验上报链路。');
5848
+ return { success: false, status: 0, body: 'plugin authorization missing' };
5849
+ }
5850
+
5851
+ // 1) 用插件【实际配置的】token 打 ingest(空 spans)——这才是运行时真实用的那个 token。
5852
+ const authHeader = pcfg.authorization; // 形如 "Bearer czu_xxx"
5853
+ const tracesUrl = (pcfg.endpoint
5854
+ ? `${String(pcfg.endpoint).replace(/\/+$/, '')}/v1/traces`
5855
+ : getOtelTracesUrl(cloud));
5856
+ const workspaceId = pcfg.workspaceId || WORKSPACE_ID;
5857
+ let res;
5858
+ try {
5859
+ res = await httpsPost(
5860
+ tracesUrl,
5861
+ { resourceSpans: [] },
5862
+ { Authorization: authHeader, 'cozeloop-workspace-id': String(workspaceId) },
5863
+ );
5864
+ } catch (e) {
5865
+ warn(`openclaw 插件 token 上报探测失败: ${e.message}`);
5866
+ return { success: false, status: 0, body: e.message };
5867
+ }
5868
+ const success = res.status >= 200 && res.status < 300;
5869
+ const tokenPrefix = authHeader.replace(/^Bearer\s+/i, '').slice(0, 12);
5870
+
5871
+ if (success) {
5872
+ ok(`openclaw 插件实际 token 上报正常 (token=${tokenPrefix}..., HTTP ${res.status})`);
5873
+ } else {
5874
+ warn(`openclaw 插件实际 token 上报失败: HTTP ${res.status} (token=${tokenPrefix}...)`);
5875
+ const snippet = (res.body || '').slice(0, 300);
5876
+ if (snippet) console.log(snippet);
5877
+ // 4100/401 = 该 token 已失效。指出根因与修复方式。
5878
+ if (res.status === 401 || /\b4100\b/.test(res.body || '')) {
5879
+ info('插件配置的 token 已失效。运行时上报会 401 → OTLP 抛 unhandled rejection → gateway 崩溃 → span 丢失。');
5880
+ info('修复:重跑 `node index.js --agent=openclaw --force` 写入新 token(local 会从 ~/.cozeloop 自动刷新)。');
5881
+ }
5882
+ }
5883
+
5884
+ // 2) 仅 local:检测实际加载的插件是否具备 token 自动刷新能力。
5885
+ // cloud 主动 disableLocalCredentials,不刷新,检测无意义。
5886
+ if (!cloud) {
5887
+ const refreshOk = openClawPluginHasRefresh(home);
5888
+ if (refreshOk === true) {
5889
+ ok('openclaw 插件具备 token 自动刷新能力 (getRefreshedToken)。');
5890
+ } else if (refreshOk === false) {
5891
+ warn('本机加载的 openclaw 插件【无 token 刷新逻辑】,token 过期后会反复 401 崩 gateway。');
5892
+ info('修复:重跑 `node index.js --agent=openclaw --force` 安装带刷新逻辑的新插件。');
5893
+ }
5894
+ // refreshOk === null:定位不到插件文件,不下结论(不误报)。
5895
+ }
5896
+
5897
+ return { success, status: res.status, body: res.body || '' };
5898
+ }
5899
+
5900
+ // 检测本机【实际加载的】openclaw trace 插件是否含 token 刷新逻辑(getRefreshedToken)。
5901
+ // 返回 true=有 / false=无 / null=定位不到插件文件(不下结论)。
5902
+ // 探测顺序:openclaw plugins list 给出的真实路径 > onboard 安装位置 ~/.cozeloop/openclaw-plugin
5903
+ // > 历史手改位置 ~/.openclaw/workspace/cozeloop-trace-fix。
5904
+ function openClawPluginHasRefresh(home) {
5905
+ const candidates = [];
5906
+ // openclaw plugins list 拿实际加载路径(最准——能发现 cozeloop-trace-fix 这类残留旧插件)
5907
+ try {
5908
+ const { execSync } = require('child_process');
5909
+ const out = execSync('openclaw plugins list', { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
5910
+ for (const line of out.split(/\r?\n/)) {
5911
+ if (!/cozeloop|trace/i.test(line)) continue;
5912
+ const m = line.match(/(\/[^\s'"]+)/); // 抓行内绝对路径
5913
+ if (m) candidates.push(m[1]);
5914
+ }
5915
+ } catch { /* CLI 不可用则回退已知路径 */ }
5916
+ candidates.push(path.join(home, '.cozeloop', 'openclaw-plugin'));
5917
+ candidates.push(path.join(home, '.openclaw', 'workspace', 'cozeloop-trace-fix'));
5918
+
5919
+ let foundAny = false;
5920
+ for (const base of candidates) {
5921
+ for (const rel of ['dist/cozeloop-exporter.js', 'dist/index.js', 'cozeloop-exporter.js', 'index.js']) {
5922
+ const f = path.isAbsolute(rel) ? rel : path.join(base, rel);
5923
+ try {
5924
+ if (!fs.existsSync(f)) continue;
5925
+ foundAny = true;
5926
+ if (fs.readFileSync(f, 'utf8').includes('getRefreshedToken')) return true;
5927
+ } catch { /* ignore */ }
5928
+ }
5929
+ }
5930
+ return foundAny ? false : null;
5931
+ }
5932
+
5135
5933
  function httpsGet(url, headers) {
5136
5934
  return new Promise((resolve, reject) => {
5137
5935
  // 合并 PPE 泳道 header
@@ -5463,7 +6261,22 @@ async function main() {
5463
6261
  const token = await getValidToken(); // 无凭证会自动走登录/刷新
5464
6262
  console.log('');
5465
6263
  const result = await verifyTraceReport(token, WORKSPACE_ID, args.pairCode, getOtelTracesUrl(false));
5466
- process.exit(result.success ? 0 : 1);
6264
+ // 若本机装了 openclaw 插件,额外校验插件【实际配置的静态 token】——通用 verify 用刚刷新的
6265
+ // token 测不到它。任一失败则整体判失败(exit 1)。
6266
+ let ocOk = true;
6267
+ const ocConfigPath = path.join(resolveHomeDir(false), '.openclaw', 'openclaw.json');
6268
+ if (fs.existsSync(ocConfigPath)) {
6269
+ try {
6270
+ const cfg = JSON.parse(fs.readFileSync(ocConfigPath, 'utf8'));
6271
+ if (cfg?.plugins?.entries?.['openclaw-cozeloop-trace']?.config?.authorization) {
6272
+ console.log('');
6273
+ info('检测到 openclaw cozeloop-trace 插件,校验其实际 token...');
6274
+ const ocRes = await verifyOpenClawTraceLink(false);
6275
+ ocOk = ocRes.success;
6276
+ }
6277
+ } catch { /* 读不了就跳过 openclaw 校验 */ }
6278
+ }
6279
+ process.exit(result.success && ocOk ? 0 : 1);
5467
6280
  }
5468
6281
 
5469
6282
  const { agent } = args;
@@ -5593,9 +6406,15 @@ async function main() {
5593
6406
 
5594
6407
  // Step 5: Verify trace reporting end-to-end
5595
6408
  info('Step 5/5: 验证 trace 上报链路...');
5596
- const verifyResult = args.cloud
5597
- ? await verifyTraceReportViaSdk(token, WORKSPACE_ID, args.pairCode, pythonCmd || 'python3', tokenSource)
5598
- : await verifyTraceReport(token, WORKSPACE_ID, args.pairCode, getOtelTracesUrl(false));
6409
+ // openclaw 走专属校验:claude-code/codex 的 verify 用主流程刚 getValidToken() 刷新过的
6410
+ // 有效 token 直发,测不到 openclaw 插件【写死在 openclaw.json 里的静态 token】是否失效
6411
+ // (插件不读这个临时 token)。openclaw 必须用插件实际配置的 authorization 打 ingest,
6412
+ // 才能真实反映运行时上报会不会 401。cloud/local 配置位置一致,统一走这条。
6413
+ const verifyResult = agent === 'openclaw'
6414
+ ? await verifyOpenClawTraceLink(args.cloud)
6415
+ : args.cloud
6416
+ ? await verifyTraceReportViaSdk(token, WORKSPACE_ID, args.pairCode, pythonCmd || 'python3', tokenSource)
6417
+ : await verifyTraceReport(token, WORKSPACE_ID, args.pairCode, getOtelTracesUrl(false));
5599
6418
  if (verifyResult.success) {
5600
6419
  cloudResult.verify = 'ok';
5601
6420
  } else if (CLOUD_MODE) {