coze_lab 0.1.35 → 0.1.37

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coze_lab",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
4
4
  "description": "Configure local AI agents (Claude Code, Codex, OpenClaw) to report traces to CozeLoop",
5
5
  "keywords": [
6
6
  "cozeloop",
@@ -105,11 +105,13 @@ else:
105
105
 
106
106
  # --- Configuration ---
107
107
  DEBUG = os.environ.get("CC_COZELOOP_DEBUG", "").lower() == "true"
108
- _COZELOOP_CLIENT_ID = "46371084383473718052118955183420.app.coze"
108
+ _COZELOOP_CLIENT_ID = "08972682140163281554629748278108.app.coze"
109
109
  _COZE_API = "https://api.coze.cn"
110
110
  _OTEL_SUFFIX = "/v1/loop/opentelemetry"
111
111
  _REFRESH_THRESHOLD = 10 * 60 # refresh when < 10 minutes remain
112
- _DEFAULT_WORKSPACE_ID = "7644910356078837760" # hardcoded spaceID fallback
112
+ _FORCE_REFRESH_COOLDOWN_MS = 60 * 1000
113
+ _REFRESH_LOCK_STALE = 30
114
+ _DEFAULT_WORKSPACE_ID = "7649231955045072915" # hardcoded spaceID fallback
113
115
 
114
116
 
115
117
  # --- coze-context parsing -------------------------------------------------
@@ -255,6 +257,12 @@ def debug_log(message: str):
255
257
  def _get_credentials_path() -> Path:
256
258
  return Path.home() / ".cozeloop" / "credentials.json"
257
259
 
260
+ def _get_refresh_state_path() -> Path:
261
+ return Path.home() / ".cozeloop" / "refresh-state.json"
262
+
263
+ def _get_refresh_lock_path() -> Path:
264
+ return Path.home() / ".cozeloop" / "refresh-state.lock"
265
+
258
266
  def _load_credentials() -> Optional[Dict]:
259
267
  path = _get_credentials_path()
260
268
  if not path.exists():
@@ -270,6 +278,55 @@ def _save_credentials(creds: Dict):
270
278
  path.write_text(json.dumps(creds, indent=2))
271
279
  os.chmod(path, 0o600)
272
280
 
281
+ def _load_refresh_state() -> Dict[str, Any]:
282
+ path = _get_refresh_state_path()
283
+ if not path.exists():
284
+ return {}
285
+ try:
286
+ return json.loads(path.read_text())
287
+ except Exception:
288
+ return {}
289
+
290
+ def _save_refresh_state(patch: Dict[str, Any]):
291
+ path = _get_refresh_state_path()
292
+ try:
293
+ path.parent.mkdir(parents=True, exist_ok=True)
294
+ state = _load_refresh_state()
295
+ state.update(patch)
296
+ path.write_text(json.dumps(state, indent=2))
297
+ os.chmod(path, 0o600)
298
+ except Exception:
299
+ pass
300
+
301
+ def _with_refresh_lock(fn):
302
+ lock_path = _get_refresh_lock_path()
303
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
304
+ for _ in range(24):
305
+ fd = None
306
+ try:
307
+ fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
308
+ try:
309
+ return fn()
310
+ finally:
311
+ try:
312
+ os.close(fd)
313
+ except Exception:
314
+ pass
315
+ try:
316
+ lock_path.unlink()
317
+ except Exception:
318
+ pass
319
+ except FileExistsError:
320
+ try:
321
+ if time.time() - lock_path.stat().st_mtime > _REFRESH_LOCK_STALE:
322
+ lock_path.unlink()
323
+ continue
324
+ except Exception:
325
+ pass
326
+ time.sleep(0.25)
327
+ hook_log("forced refresh skipped because refresh lock is busy")
328
+ return None
329
+
273
330
  def _refresh_token(refresh_token: str) -> Optional[str]:
274
331
  """Call Coze refresh token API. Returns new access_token or None on failure."""
275
332
  try:
@@ -283,8 +340,6 @@ def _refresh_token(refresh_token: str) -> Optional[str]:
283
340
  data=payload,
284
341
  headers={
285
342
  "Content-Type": "application/json",
286
- "x-tt-env": "ppe_cozelab",
287
- "x-use-ppe": "1",
288
343
  },
289
344
  )
290
345
  with urllib.request.urlopen(req, timeout=10) as resp:
@@ -304,6 +359,56 @@ def _refresh_token(refresh_token: str) -> Optional[str]:
304
359
  debug_log(f"Token refresh failed: {e}")
305
360
  return None
306
361
 
362
+ def force_refresh_token_after_upload_failure(reason: str = "upload_failure", current_token: Optional[str] = None) -> Optional[str]:
363
+ """Force-refresh local credentials after an upload failure, with cross-process throttling."""
364
+ is_cloud = os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes")
365
+ if is_cloud:
366
+ hook_log("upload failure token refresh skipped in cloud mode")
367
+ return None
368
+
369
+ creds = _load_credentials()
370
+ if not creds or not creds.get("refresh_token"):
371
+ hook_log("upload failure token refresh skipped; no local refresh_token")
372
+ return None
373
+
374
+ cached = creds.get("access_token")
375
+ now_ms = int(time.time() * 1000)
376
+ last_ms = int(_load_refresh_state().get("last_forced_refresh_ms", 0) or 0)
377
+ if last_ms and now_ms - last_ms < _FORCE_REFRESH_COOLDOWN_MS:
378
+ if cached and cached != current_token:
379
+ hook_log("forced refresh throttled; reuse newer cached token")
380
+ return cached
381
+ hook_log(f"forced refresh throttled ageMs={now_ms - last_ms}")
382
+ return None
383
+
384
+ def _locked_refresh():
385
+ locked_creds = _load_credentials()
386
+ if not locked_creds or not locked_creds.get("refresh_token"):
387
+ hook_log("forced refresh skipped in lock; credentials missing")
388
+ return None
389
+ locked_cached = locked_creds.get("access_token")
390
+ locked_now_ms = int(time.time() * 1000)
391
+ locked_last_ms = int(_load_refresh_state().get("last_forced_refresh_ms", 0) or 0)
392
+ if locked_last_ms and locked_now_ms - locked_last_ms < _FORCE_REFRESH_COOLDOWN_MS:
393
+ if locked_cached and locked_cached != current_token:
394
+ hook_log("forced refresh already done by another process; reuse token")
395
+ return locked_cached
396
+ hook_log(f"forced refresh skipped in lock ageMs={locked_now_ms - locked_last_ms}")
397
+ return None
398
+ _save_refresh_state({
399
+ "last_forced_refresh_ms": locked_now_ms,
400
+ "last_reason": (reason or "upload_failure")[:120],
401
+ })
402
+ hook_log(f"forced token refresh after upload failure reason={(reason or 'upload_failure')[:120]}")
403
+ refreshed = _refresh_token(locked_creds["refresh_token"])
404
+ if refreshed:
405
+ hook_log("forced token refresh OK")
406
+ else:
407
+ hook_log("forced token refresh FAILED")
408
+ return refreshed
409
+
410
+ return _with_refresh_lock(_locked_refresh)
411
+
307
412
  def _normalize_api_base_url(url: str) -> str:
308
413
  base = (url or "").strip().rstrip("/")
309
414
  if not base:
@@ -962,7 +1067,7 @@ def _build_history_messages(history_turns: List[Dict[str, Any]]) -> list:
962
1067
 
963
1068
  # --- CozeLoop Trace Reporting ---
964
1069
 
965
- def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history_turns: Optional[List[Dict[str, Any]]] = None):
1070
+ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history_turns: Optional[List[Dict[str, Any]]] = None, retry_on_auth_failure: bool = True):
966
1071
  """Send conversation turns to CozeLoop.
967
1072
 
968
1073
  Span hierarchy:
@@ -1462,6 +1567,12 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
1462
1567
 
1463
1568
  if upload_events:
1464
1569
  debug_log(f"Upload failed, state not advanced. Last failure: {upload_events[-1][:500]}")
1570
+ if retry_on_auth_failure:
1571
+ new_token = force_refresh_token_after_upload_failure(upload_events[-1], token)
1572
+ if new_token:
1573
+ os.environ["COZELOOP_API_TOKEN"] = new_token
1574
+ hook_log("retry upload once after forced token refresh")
1575
+ return send_turns_to_cozeloop(turns, session_id, history_turns, retry_on_auth_failure=False)
1465
1576
  return None
1466
1577
 
1467
1578
  return True
@@ -42,10 +42,12 @@ from pathlib import Path
42
42
  from typing import Optional, List, Dict, Any
43
43
 
44
44
  # --- Token refresh --------------------------------------------------------
45
- _COZELOOP_CLIENT_ID = "46371084383473718052118955183420.app.coze"
45
+ _COZELOOP_CLIENT_ID = "08972682140163281554629748278108.app.coze"
46
46
  _COZE_API = "https://api.coze.cn"
47
47
  _REFRESH_THRESHOLD = 10 * 60
48
- _DEFAULT_WORKSPACE_ID = "7644910356078837760" # hardcoded spaceID fallback
48
+ _FORCE_REFRESH_COOLDOWN_MS = 60 * 1000
49
+ _REFRESH_LOCK_STALE = 30
50
+ _DEFAULT_WORKSPACE_ID = "7649231955045072915" # hardcoded spaceID fallback
49
51
  _OTEL_SUFFIX = "/v1/loop/opentelemetry"
50
52
 
51
53
 
@@ -168,6 +170,12 @@ def _make_finish_event_processor(upload_events: Optional[List[str]] = None):
168
170
  def _get_credentials_path() -> Path:
169
171
  return Path.home() / ".cozeloop" / "credentials.json"
170
172
 
173
+ def _get_refresh_state_path() -> Path:
174
+ return Path.home() / ".cozeloop" / "refresh-state.json"
175
+
176
+ def _get_refresh_lock_path() -> Path:
177
+ return Path.home() / ".cozeloop" / "refresh-state.lock"
178
+
171
179
  def _load_credentials():
172
180
  path = _get_credentials_path()
173
181
  if not path.exists():
@@ -183,6 +191,55 @@ def _save_credentials(creds):
183
191
  path.write_text(json.dumps(creds, indent=2))
184
192
  os.chmod(path, 0o600)
185
193
 
194
+ def _load_refresh_state():
195
+ path = _get_refresh_state_path()
196
+ if not path.exists():
197
+ return {}
198
+ try:
199
+ return json.loads(path.read_text())
200
+ except Exception:
201
+ return {}
202
+
203
+ def _save_refresh_state(patch):
204
+ path = _get_refresh_state_path()
205
+ try:
206
+ path.parent.mkdir(parents=True, exist_ok=True)
207
+ state = _load_refresh_state()
208
+ state.update(patch)
209
+ path.write_text(json.dumps(state, indent=2))
210
+ os.chmod(path, 0o600)
211
+ except Exception:
212
+ pass
213
+
214
+ def _with_refresh_lock(fn):
215
+ lock_path = _get_refresh_lock_path()
216
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
217
+ for _ in range(24):
218
+ fd = None
219
+ try:
220
+ fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
221
+ try:
222
+ return fn()
223
+ finally:
224
+ try:
225
+ os.close(fd)
226
+ except Exception:
227
+ pass
228
+ try:
229
+ lock_path.unlink()
230
+ except Exception:
231
+ pass
232
+ except FileExistsError:
233
+ try:
234
+ if time.time() - lock_path.stat().st_mtime > _REFRESH_LOCK_STALE:
235
+ lock_path.unlink()
236
+ continue
237
+ except Exception:
238
+ pass
239
+ time.sleep(0.25)
240
+ hook_log("forced refresh skipped because refresh lock is busy")
241
+ return None
242
+
186
243
  def _refresh_token(refresh_tok: str):
187
244
  try:
188
245
  payload = json.dumps({
@@ -195,8 +252,6 @@ def _refresh_token(refresh_tok: str):
195
252
  data=payload,
196
253
  headers={
197
254
  "Content-Type": "application/json",
198
- "x-tt-env": "ppe_cozelab",
199
- "x-use-ppe": "1",
200
255
  },
201
256
  )
202
257
  with urllib.request.urlopen(req, timeout=10) as resp:
@@ -215,6 +270,55 @@ def _refresh_token(refresh_tok: str):
215
270
  pass
216
271
  return None
217
272
 
273
+ def force_refresh_token_after_upload_failure(reason: str = "upload_failure", current_token: Optional[str] = None):
274
+ is_cloud = os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes")
275
+ if is_cloud:
276
+ hook_log("upload failure token refresh skipped in cloud mode")
277
+ return None
278
+
279
+ creds = _load_credentials()
280
+ if not creds or not creds.get("refresh_token"):
281
+ hook_log("upload failure token refresh skipped; no local refresh_token")
282
+ return None
283
+
284
+ cached = creds.get("access_token")
285
+ now_ms = int(time.time() * 1000)
286
+ last_ms = int(_load_refresh_state().get("last_forced_refresh_ms", 0) or 0)
287
+ if last_ms and now_ms - last_ms < _FORCE_REFRESH_COOLDOWN_MS:
288
+ if cached and cached != current_token:
289
+ hook_log("forced refresh throttled; reuse newer cached token")
290
+ return cached
291
+ hook_log(f"forced refresh throttled ageMs={now_ms - last_ms}")
292
+ return None
293
+
294
+ def _locked_refresh():
295
+ locked_creds = _load_credentials()
296
+ if not locked_creds or not locked_creds.get("refresh_token"):
297
+ hook_log("forced refresh skipped in lock; credentials missing")
298
+ return None
299
+ locked_cached = locked_creds.get("access_token")
300
+ locked_now_ms = int(time.time() * 1000)
301
+ locked_last_ms = int(_load_refresh_state().get("last_forced_refresh_ms", 0) or 0)
302
+ if locked_last_ms and locked_now_ms - locked_last_ms < _FORCE_REFRESH_COOLDOWN_MS:
303
+ if locked_cached and locked_cached != current_token:
304
+ hook_log("forced refresh already done by another process; reuse token")
305
+ return locked_cached
306
+ hook_log(f"forced refresh skipped in lock ageMs={locked_now_ms - locked_last_ms}")
307
+ return None
308
+ _save_refresh_state({
309
+ "last_forced_refresh_ms": locked_now_ms,
310
+ "last_reason": (reason or "upload_failure")[:120],
311
+ })
312
+ hook_log(f"forced token refresh after upload failure reason={(reason or 'upload_failure')[:120]}")
313
+ refreshed = _refresh_token(locked_creds["refresh_token"])
314
+ if refreshed:
315
+ hook_log("forced token refresh OK")
316
+ else:
317
+ hook_log("forced token refresh FAILED")
318
+ return refreshed
319
+
320
+ return _with_refresh_lock(_locked_refresh)
321
+
218
322
 
219
323
  def _normalize_api_base_url(url: str) -> str:
220
324
  base = (url or "").strip().rstrip("/")
@@ -967,7 +1071,8 @@ def _make_model_message(role: str, content: str = "", tool_calls: list = None,
967
1071
 
968
1072
 
969
1073
  def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_name: str = "codex",
970
- history_context: Optional[List[Dict[str, Any]]] = None) -> Optional[List[Dict[str, Any]]]:
1074
+ history_context: Optional[List[Dict[str, Any]]] = None,
1075
+ retry_on_auth_failure: bool = True) -> Optional[List[Dict[str, Any]]]:
971
1076
  """Send conversation turns to CozeLoop for tracing.
972
1077
 
973
1078
  Span hierarchy:
@@ -1389,6 +1494,18 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1389
1494
 
1390
1495
  if upload_events:
1391
1496
  hook_log(f"upload failed state not advanced failures={len(upload_events)} detail={upload_events[-1][:500]}")
1497
+ if retry_on_auth_failure:
1498
+ new_token = force_refresh_token_after_upload_failure(upload_events[-1], token)
1499
+ if new_token:
1500
+ os.environ["COZELOOP_API_TOKEN"] = new_token
1501
+ hook_log("retry upload once after forced token refresh")
1502
+ return send_turns_to_cozeloop(
1503
+ turns,
1504
+ session_id,
1505
+ model_name,
1506
+ history_context,
1507
+ retry_on_auth_failure=False,
1508
+ )
1392
1509
  return None
1393
1510
 
1394
1511
  return ctx
@@ -5,7 +5,7 @@ import { ATTR_SERVICE_NAME, ATTR_SERVICE_INSTANCE_ID } from "@opentelemetry/sema
5
5
  import { hostname } from "os";
6
6
  import { basename, join } from "path";
7
7
  import { createRequire } from "node:module";
8
- import { readFileSync, writeFileSync, mkdirSync, appendFileSync } from "fs";
8
+ import { readFileSync, writeFileSync, mkdirSync, appendFileSync, openSync, closeSync, unlinkSync, statSync } from "fs";
9
9
  import { homedir } from "os";
10
10
  import http from "http";
11
11
  import https from "https";
@@ -22,10 +22,14 @@ const CLIENT_USER_AGENT = {
22
22
  };
23
23
 
24
24
  // ── Token refresh helpers ─────────────────────────────────────────────────
25
- const _CLIENT_ID = "46371084383473718052118955183420.app.coze";
25
+ const _CLIENT_ID = "08972682140163281554629748278108.app.coze";
26
26
  const _COZE_API = "https://api.coze.cn";
27
27
  const _REFRESH_THRESHOLD_MS = 10 * 60 * 1000;
28
+ const _FORCE_REFRESH_COOLDOWN_MS = 60 * 1000;
29
+ const _REFRESH_LOCK_STALE_MS = 30 * 1000;
28
30
  const _CREDS_PATH = join(homedir(), ".cozeloop", "credentials.json");
31
+ const _REFRESH_STATE_PATH = join(homedir(), ".cozeloop", "refresh-state.json");
32
+ const _REFRESH_LOCK_PATH = join(homedir(), ".cozeloop", "refresh-state.lock");
29
33
 
30
34
  function _loadCreds() {
31
35
  try { return JSON.parse(readFileSync(_CREDS_PATH, "utf8")); }
@@ -39,12 +43,66 @@ function _saveCreds(c) {
39
43
  } catch { /* non-fatal */ }
40
44
  }
41
45
 
46
+ function _loadRefreshState() {
47
+ try { return JSON.parse(readFileSync(_REFRESH_STATE_PATH, "utf8")); }
48
+ catch { return {}; }
49
+ }
50
+
51
+ function _saveRefreshState(patch) {
52
+ try {
53
+ mkdirSync(join(homedir(), ".cozeloop"), { recursive: true });
54
+ const state = { ..._loadRefreshState(), ...patch };
55
+ writeFileSync(_REFRESH_STATE_PATH, JSON.stringify(state, null, 2), { mode: 0o600 });
56
+ } catch { /* non-fatal */ }
57
+ }
58
+
59
+ function _authorizationFromCreds(creds) {
60
+ return creds?.access_token ? `Bearer ${creds.access_token}` : null;
61
+ }
62
+
63
+ function _sleep(ms) {
64
+ return new Promise(resolve => setTimeout(resolve, ms));
65
+ }
66
+
67
+ async function _withRefreshLock(logFile, fn) {
68
+ mkdirSync(join(homedir(), ".cozeloop"), { recursive: true });
69
+ for (let i = 0; i < 24; i++) {
70
+ let fd = null;
71
+ try {
72
+ fd = openSync(_REFRESH_LOCK_PATH, "wx", 0o600);
73
+ try {
74
+ return await fn();
75
+ }
76
+ finally {
77
+ try { if (fd !== null) closeSync(fd); } catch { /* ignore */ }
78
+ try { unlinkSync(_REFRESH_LOCK_PATH); } catch { /* ignore */ }
79
+ }
80
+ }
81
+ catch (err) {
82
+ if (err?.code !== "EEXIST") {
83
+ fileLog(logFile, `[auth] refresh lock error=${err?.message || err}`);
84
+ return null;
85
+ }
86
+ try {
87
+ const ageMs = Date.now() - statSync(_REFRESH_LOCK_PATH).mtimeMs;
88
+ if (ageMs > _REFRESH_LOCK_STALE_MS) {
89
+ unlinkSync(_REFRESH_LOCK_PATH);
90
+ continue;
91
+ }
92
+ } catch { /* ignore */ }
93
+ await _sleep(250);
94
+ }
95
+ }
96
+ fileLog(logFile, "[auth] refresh lock busy; skip forced refresh");
97
+ return null;
98
+ }
99
+
42
100
  async function _refreshToken(refreshTok) {
43
101
  return new Promise((resolve) => {
44
102
  const body = JSON.stringify({ grant_type: "refresh_token", client_id: _CLIENT_ID, refresh_token: refreshTok });
45
103
  const req = https.request(`${_COZE_API}/api/permission/oauth2/token`, {
46
104
  method: "POST",
47
- headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body), "x-tt-env": "ppe_cozelab", "x-use-ppe": "1" },
105
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
48
106
  }, (res) => {
49
107
  let buf = "";
50
108
  res.on("data", c => buf += c);
@@ -72,8 +130,65 @@ async function _refreshToken(refreshTok) {
72
130
  });
73
131
  }
74
132
 
133
+ async function _forceRefreshToken(currentAuthorization, opts = {}) {
134
+ const logFile = opts.logFile;
135
+ const creds = _loadCreds();
136
+ if (!creds?.refresh_token) {
137
+ fileLog(logFile, "[auth] upload failed but no local refresh_token is available");
138
+ return null;
139
+ }
140
+
141
+ const cachedAuth = _authorizationFromCreds(creds);
142
+ const now = Date.now();
143
+ const state = _loadRefreshState();
144
+ const lastForced = Number(state.last_forced_refresh_ms || 0);
145
+ if (lastForced && now - lastForced < _FORCE_REFRESH_COOLDOWN_MS) {
146
+ if (cachedAuth && cachedAuth !== currentAuthorization) {
147
+ fileLog(logFile, `[auth] forced refresh throttled; reuse newer cached token tokenLength=${cachedAuth.length}`);
148
+ return cachedAuth;
149
+ }
150
+ fileLog(logFile, `[auth] forced refresh throttled ageMs=${now - lastForced}`);
151
+ return null;
152
+ }
153
+
154
+ return _withRefreshLock(logFile, async () => {
155
+ const lockedCreds = _loadCreds();
156
+ if (!lockedCreds?.refresh_token) {
157
+ fileLog(logFile, "[auth] forced refresh skipped; credentials disappeared");
158
+ return null;
159
+ }
160
+ const lockedCachedAuth = _authorizationFromCreds(lockedCreds);
161
+ const lockedState = _loadRefreshState();
162
+ const lockedLastForced = Number(lockedState.last_forced_refresh_ms || 0);
163
+ const lockedNow = Date.now();
164
+ if (lockedLastForced && lockedNow - lockedLastForced < _FORCE_REFRESH_COOLDOWN_MS) {
165
+ if (lockedCachedAuth && lockedCachedAuth !== currentAuthorization) {
166
+ fileLog(logFile, `[auth] forced refresh already done by another process; reuse tokenLength=${lockedCachedAuth.length}`);
167
+ return lockedCachedAuth;
168
+ }
169
+ fileLog(logFile, `[auth] forced refresh skipped in lock ageMs=${lockedNow - lockedLastForced}`);
170
+ return null;
171
+ }
172
+
173
+ _saveRefreshState({
174
+ last_forced_refresh_ms: lockedNow,
175
+ last_reason: String(opts.reason || "upload_failure").slice(0, 120),
176
+ });
177
+ fileLog(logFile, `[auth] forced token refresh after local upload failure reason=${String(opts.reason || "upload_failure").slice(0, 120)}`);
178
+ const newToken = await _refreshToken(lockedCreds.refresh_token);
179
+ if (!newToken) {
180
+ fileLog(logFile, "[auth] forced token refresh FAILED");
181
+ return null;
182
+ }
183
+ const freshAuth = `Bearer ${newToken}`;
184
+ fileLog(logFile, `[auth] forced token refresh OK tokenLength=${freshAuth.length}`);
185
+ return freshAuth;
186
+ });
187
+ }
188
+
75
189
  async function getRefreshedToken(currentAuthorization, opts = {}) {
76
190
  if (opts.disableLocalCredentials) return currentAuthorization;
191
+ if (opts.force) return _forceRefreshToken(currentAuthorization, opts);
77
192
  const creds = _loadCreds();
78
193
  if (!creds) return currentAuthorization; // no creds file, keep as-is
79
194
  const remaining = (creds.expires_at ?? 0) - Date.now();
@@ -287,6 +402,7 @@ class CozeloopIngestExporter {
287
402
  this.logFile = config.logFile;
288
403
  this.workspaceId = config.workspaceId;
289
404
  this.serviceName = config.serviceName;
405
+ this.onAuthFailure = config.onAuthFailure;
290
406
  this.shutdownRequested = false;
291
407
  fileLog(this.logFile, `[ingest] exporter ready url=${this.url} workspaceId=${this.workspaceId}`);
292
408
  }
@@ -310,7 +426,43 @@ class CozeloopIngestExporter {
310
426
  serviceName: this.serviceName,
311
427
  })),
312
428
  };
313
- fileLog(this.logFile, `[ingest] POST url=${this.url} spans=${body.spans.length}`);
429
+ try {
430
+ await this.postBody(body, false);
431
+ }
432
+ catch (err) {
433
+ const freshAuth = await this.refreshAuthorizationAfterFailure(err);
434
+ if (!freshAuth) {
435
+ throw err;
436
+ }
437
+ this.headers = { ...this.headers, Authorization: freshAuth };
438
+ fileLog(this.logFile, `[ingest] retry after token refresh url=${this.url} spans=${body.spans.length}`);
439
+ try {
440
+ await this.postBody(body, true);
441
+ }
442
+ catch (retryErr) {
443
+ throw new Error(`retry after token refresh failed: ${retryErr?.message || retryErr}`);
444
+ }
445
+ }
446
+ }
447
+ async refreshAuthorizationAfterFailure(err) {
448
+ if (typeof this.onAuthFailure !== "function")
449
+ return null;
450
+ fileLog(this.logFile, `[auth] local upload failure triggers refresh attempt err=${String(err?.message || err).slice(0, 300)}`);
451
+ try {
452
+ const current = this.headers?.Authorization || "";
453
+ const fresh = await this.onAuthFailure(current, err);
454
+ if (fresh && fresh !== current) {
455
+ return fresh;
456
+ }
457
+ fileLog(this.logFile, "[auth] no newer authorization available for retry");
458
+ }
459
+ catch (refreshErr) {
460
+ fileLog(this.logFile, `[auth] refresh attempt threw error=${refreshErr?.message || refreshErr}`);
461
+ }
462
+ return null;
463
+ }
464
+ async postBody(body, retry) {
465
+ fileLog(this.logFile, `[ingest] ${retry ? "RETRY " : ""}POST url=${this.url} spans=${body.spans.length}`);
314
466
  const res = await postJson(this.url, body, this.headers);
315
467
  if (res.status < 200 || res.status >= 300) {
316
468
  const snippet = String(res.body || "").slice(0, 300);
@@ -331,7 +483,7 @@ class CozeloopIngestExporter {
331
483
  }
332
484
  }
333
485
  }
334
- fileLog(this.logFile, `[ingest] OK HTTP ${res.status} spans=${body.spans.length}`);
486
+ fileLog(this.logFile, `[ingest] OK HTTP ${res.status} spans=${body.spans.length}${retry ? " retry=1" : ""}`);
335
487
  }
336
488
  async forceFlush() {
337
489
  return;
@@ -416,6 +568,20 @@ export class CozeloopExporter {
416
568
  this.config.authorization = "";
417
569
  }
418
570
  }
571
+ async refreshAuthAfterUploadFailure(currentAuthorization, err) {
572
+ const fresh = await getRefreshedToken(currentAuthorization || this.config.authorization, {
573
+ disableLocalCredentials: this.config.disableLocalCredentials,
574
+ force: true,
575
+ reason: err?.message || "upload_failure",
576
+ logFile: this.config.logFile,
577
+ });
578
+ if (fresh && fresh !== currentAuthorization) {
579
+ this.api.logger.info("[CozeloopTrace] Token refreshed after upload failure; retrying export...");
580
+ this.config.authorization = fresh;
581
+ return fresh;
582
+ }
583
+ return null;
584
+ }
419
585
  async ensureInitialized() {
420
586
  if (this.initialized)
421
587
  return;
@@ -443,12 +609,11 @@ export class CozeloopExporter {
443
609
  logFile: this.config.logFile,
444
610
  workspaceId,
445
611
  serviceName: this.config.serviceName,
612
+ onAuthFailure: (currentAuthorization, err) => this.refreshAuthAfterUploadFailure(currentAuthorization, err),
446
613
  headers: {
447
614
  "Authorization": authorization,
448
615
  "User-Agent": `openclaw-cozeloop-trace/${PLUGIN_VERSION} node/${process.versions.node}`,
449
616
  "X-Coze-Client-User-Agent": JSON.stringify(CLIENT_USER_AGENT),
450
- "x-tt-env": "ppe_cozelab",
451
- "x-use-ppe": "1",
452
617
  },
453
618
  });
454
619
  this.provider = new BasicTracerProvider({ resource });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-cozeloop-trace",
3
3
  "name": "OpenClaw CozeLoop Trace",
4
- "version": "0.1.18",
4
+ "version": "0.1.19",
5
5
  "description": "Report OpenClaw execution traces to CozeLoop via OpenTelemetry",
6
6
  "type": "plugin",
7
7
  "entry": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cozeloop/openclaw-cozeloop-trace",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "OpenClaw Plugin for reporting traces to CozeLoop via OpenTelemetry",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -7,7 +7,7 @@ Writes the fresh token back so subsequent Stop hooks pick it up.
7
7
  import json, os, sys, time, urllib.request
8
8
  from pathlib import Path
9
9
 
10
- CLIENT_ID = "46371084383473718052118955183420.app.coze"
10
+ CLIENT_ID = "08972682140163281554629748278108.app.coze"
11
11
  COZE_API = "https://api.coze.cn"
12
12
  THRESHOLD = 10 * 60 # 10 minutes
13
13
  CREDS = Path.home() / ".cozeloop" / "credentials.json"
@@ -27,8 +27,6 @@ def refresh(rt):
27
27
  req = urllib.request.Request(f"{COZE_API}/api/permission/oauth2/token",
28
28
  data=body, headers={
29
29
  "Content-Type":"application/json",
30
- "x-tt-env":"ppe_cozelab",
31
- "x-use-ppe":"1",
32
30
  })
33
31
  with urllib.request.urlopen(req, timeout=10) as r:
34
32
  d = json.loads(r.read())