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/README.md +5 -5
- package/index.js +115 -4733
- package/package.json +1 -1
- package/scripts/claude-code/cozeloop_hook.py +116 -5
- package/scripts/codex/cozeloop_hook.py +122 -5
- package/scripts/openclaw/dist/cozeloop-exporter.js +172 -7
- package/scripts/openclaw/openclaw.plugin.json +1 -1
- package/scripts/openclaw/package.json +1 -1
- package/scripts/shared/cozeloop_refresh.py +1 -3
package/package.json
CHANGED
|
@@ -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 = "
|
|
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
|
-
|
|
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 = "
|
|
45
|
+
_COZELOOP_CLIENT_ID = "08972682140163281554629748278108.app.coze"
|
|
46
46
|
_COZE_API = "https://api.coze.cn"
|
|
47
47
|
_REFRESH_THRESHOLD = 10 * 60
|
|
48
|
-
|
|
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
|
|
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 = "
|
|
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)
|
|
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
|
-
|
|
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 });
|
|
@@ -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 = "
|
|
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())
|