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 +914 -95
- package/package.json +4 -1
- package/scripts/claude-code/cozeloop_hook.py +110 -31
- package/scripts/codex/cozeloop_hook.py +106 -30
- package/scripts/openclaw/dist/cozeloop-exporter.js +11 -3
- package/scripts/openclaw/dist/index.js +1 -0
- package/scripts/openclaw/openclaw.plugin.json +6 -1
- package/scripts/openclaw/package.json +1 -1
- package/scripts/shared/cozeloop_refresh.py +8 -3
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={
|
|
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.
|
|
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
|
-
|
|
241
|
-
|
|
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={
|
|
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
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
if
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1404
|
-
print("Error: cozeloop SDK not found
|
|
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 (
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2343
|
+
root_tags = {
|
|
1828
2344
|
"thread_id": session_id,
|
|
1829
2345
|
"total_turns": len(turns),
|
|
1830
2346
|
"source": "codex_cli",
|
|
1831
|
-
}
|
|
1832
|
-
|
|
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("
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2503
|
+
tool_tags = {
|
|
1942
2504
|
"tool_name": tool_name,
|
|
1943
|
-
"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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2648
|
+
sa_tool_tags = {
|
|
2040
2649
|
"tool_name": sa_tool_name,
|
|
2041
|
-
"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
|
-
|
|
2106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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={
|
|
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;
|
|
4382
|
-
// Claude Code / Codex hooks `import cozeloop` at runtime
|
|
4383
|
-
//
|
|
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 "
|
|
5066
|
+
execSync(`${pythonCmd} -c "${COZELOOP_CAPABLE_PROBE}"`, { stdio: 'pipe' });
|
|
4387
5067
|
ok('cozeloop SDK — OK');
|
|
4388
5068
|
return;
|
|
4389
|
-
} catch { /*
|
|
5069
|
+
} catch { /* 未装或版本过旧 — 下面安装/升级 */ }
|
|
4390
5070
|
|
|
4391
|
-
info(
|
|
5071
|
+
info(`cozeloop SDK 不可用或版本过旧,正在安装/升级 (pip install -U '${COZELOOP_MIN_SPEC}')...`);
|
|
4392
5072
|
try {
|
|
4393
|
-
execSync(`${pythonCmd} -m pip install --quiet --upgrade
|
|
4394
|
-
// Confirm it imports now
|
|
4395
|
-
execSync(`${pythonCmd} -c "
|
|
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:
|
|
5079
|
+
'⚠ WARNING: 无法自动安装/升级 cozeloop SDK 到所需版本',
|
|
4400
5080
|
'',
|
|
4401
5081
|
'请手动安装后再使用,否则 hook 触发时 trace 上报会失败:',
|
|
4402
|
-
` ${pythonCmd} -m pip install
|
|
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
|
|
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
|
-
//
|
|
4586
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
5597
|
-
|
|
5598
|
-
|
|
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) {
|