@foxden-app/foxclaw 0.5.6 → 0.5.8
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/CHANGELOG.md +24 -0
- package/README.md +1 -1
- package/README_EN.md +1 -1
- package/dist/auth/cross_node_sync.d.ts +18 -0
- package/dist/auth/cross_node_sync.js +220 -3
- package/dist/controller/controller.js +100 -3
- package/dist/i18n.d.ts +16 -2
- package/dist/i18n.js +16 -2
- package/dist/types.d.ts +15 -0
- package/docs/cross-node-auth-sync.md +3 -0
- package/docs/user-manual.md +5 -3
- package/docs/zh/cross-node-auth-sync.md +3 -0
- package/docs/zh/user-manual.md +5 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
All notable FoxClaw changes are listed here. Each release note is bilingual so GitHub Releases and the npm package are useful to both Chinese and English readers.
|
|
4
4
|
|
|
5
|
+
## 0.5.8 - 2026-06-05
|
|
6
|
+
|
|
7
|
+
### 中文
|
|
8
|
+
- 为跨节点 auth 同步新增持久化事件环,记录 push、pull、lease、test 和远端导入的发送、接收、超时、跳过、导入、失败等阶段。
|
|
9
|
+
- `/auth sync status` 现在展示 peer 最近活跃时间和最近事件,让“peer 在线但某次请求超时”“候选失败但同步系统正常”更容易判断。
|
|
10
|
+
- 新增 `/auth sync events [过滤]` 和 `/auth sync trace <requestId>`,可按候选名、peer、事件类型或请求 ID 查看 bot_to_bot 通讯流水。
|
|
11
|
+
- 事件记录包含 requestId、peer、candidateName 和阶段详情,同时保留既有加密协议兼容性。
|
|
12
|
+
|
|
13
|
+
### English
|
|
14
|
+
- Added a persisted event ring for cross-node auth sync, recording send, receive, timeout, skip, import, and failure stages for push, pull, lease, test, and remote-import flows.
|
|
15
|
+
- `/auth sync status` now includes peer recent activity and recent events, making it easier to distinguish "peer reachable but this request timed out" from candidate-specific failures.
|
|
16
|
+
- Added `/auth sync events [filter]` and `/auth sync trace <requestId>` to inspect bot-to-bot traffic by candidate name, peer, event kind, or request ID.
|
|
17
|
+
- Event records include requestId, peer, candidateName, and stage details while keeping the existing encrypted protocol backward-compatible.
|
|
18
|
+
|
|
19
|
+
## 0.5.7 - 2026-06-05
|
|
20
|
+
|
|
21
|
+
### 中文
|
|
22
|
+
- 修复普通 `/login_device` 和 `/auth` 面板设备登录完成后只更新本机当前 auth、没有主动同步到同节点 bot home 和跨节点 peer 的问题。
|
|
23
|
+
- 设备登录成功后,FoxClaw 现在会解析当前 auth 候选并调用既有 `authCandidateUpdated` 路径,和 `/auth add <name>`、auth 切换、reload、主动刷新保持一致。
|
|
24
|
+
|
|
25
|
+
### English
|
|
26
|
+
- Fixed normal `/login_device` and `/auth` panel device-login completions updating only the local current auth without actively syncing to same-node bot homes or cross-node peers.
|
|
27
|
+
- After a successful device login, FoxClaw now resolves the current auth candidate and calls the existing `authCandidateUpdated` path, matching `/auth add <name>`, auth switching, reload, and proactive refresh behavior.
|
|
28
|
+
|
|
5
29
|
## 0.5.6 - 2026-06-04
|
|
6
30
|
|
|
7
31
|
### 中文
|
package/README.md
CHANGED
|
@@ -215,7 +215,7 @@ TG_BOT_TOKEN=123456:token_a
|
|
|
215
215
|
|
|
216
216
|
FoxClaw 仍然只运行一个系统服务。默认情况下,它会为每个 bot 启动独立 `codex app-server` 和独立 `CODEX_HOME`。因此 A 私聊运行 turn 时,B 私聊仍可独立切换自己的 `/auth`。候选凭据由 FoxClaw 在登录或刷新在线验证后镜像同步;切换或重载前还会从其他 Codex home 恢复同账号较新凭据。各 bot 的当前选择互不影响。每个 bot 首次私聊发送 `/help` 和 `/status`;`/auth` 会标明正在操作的 bot runtime,`/status` 会列出全部 bot 的连接、runtime 类型、当前 auth 和活动 turn 摘要。
|
|
217
217
|
|
|
218
|
-
多台机器共享同一合法账号池时,可以启用可选跨节点 auth 同步:`AUTH_SYNC_ENABLED=true`、`AUTH_SYNC_KEY` 和 `AUTH_SYNC_PEERS=@peer_contact_bot`。推荐每台机器只选一个联系人 bot;同一节点内的其他 bot 继续走本机 auth 镜像。多 bot 模式下,默认用 `TG_BOT_TOKENS` 的第一个 token 作为联系人 bot。FoxClaw 会通过 Telegram Bot-to-Bot 私聊传输加密 auth 包;本机验证刷新后主动 push,发现本机候选失效时主动 pull peer 已持有的有效副本,并在联系人 bot
|
|
218
|
+
多台机器共享同一合法账号池时,可以启用可选跨节点 auth 同步:`AUTH_SYNC_ENABLED=true`、`AUTH_SYNC_KEY` 和 `AUTH_SYNC_PEERS=@peer_contact_bot`。推荐每台机器只选一个联系人 bot;同一节点内的其他 bot 继续走本机 auth 镜像。多 bot 模式下,默认用 `TG_BOT_TOKENS` 的第一个 token 作为联系人 bot。FoxClaw 会通过 Telegram Bot-to-Bot 私聊传输加密 auth 包;本机验证刷新后主动 push,发现本机候选失效时主动 pull peer 已持有的有效副本,并在联系人 bot 私聊里报告发送、接收、排队、导入、失败和人工介入提示。`/auth sync events [过滤]` 和 `/auth sync trace <requestId>` 可查看最近通讯流水。跨节点恢复不会自动刷新 token,`/auth refresh all confirm` 会先申请跨节点刷新锁。完整配置、`@BotFather` 操作和验证步骤见 [跨节点 auth 同步配置指南](./docs/zh/cross-node-auth-sync.md)。
|
|
219
219
|
|
|
220
220
|
如果你需要一路 Telegram 与终端互通 session,把同一个 token 同时填入 `TG_BOT_TOKENS` 和 `TG_BOT_TOKEN`。这个 bot 使用默认 `CODEX_HOME`(未设置时通常是 `~/.codex`)和默认 auth,因此能看到终端 Codex 的本地线程;它不再享有隔离 runtime 的“互不影响”保证,切换 auth 会影响终端和其他默认 runtime。
|
|
221
221
|
|
package/README_EN.md
CHANGED
|
@@ -215,7 +215,7 @@ TG_BOT_TOKEN=123456:token_a
|
|
|
215
215
|
|
|
216
216
|
FoxClaw remains one system service. By default, it starts an independent `codex app-server` and `CODEX_HOME` for each bot. While bot A is running a turn, bot B can switch its own `/auth` selection. Candidate credentials are mirrored only after online-validated login or refresh; before switching or reloading, FoxClaw also restores a newer same-account credential from another Codex home when available. Current selections remain independent. Send `/help` and `/status` in a private chat with each bot after installation; `/auth` names the runtime being managed, and `/status` summarizes every bot's connection, runtime type, selected auth, and active turns.
|
|
217
217
|
|
|
218
|
-
When multiple machines share the same legally owned account pool, optional cross-node auth sync can be enabled with `AUTH_SYNC_ENABLED=true`, `AUTH_SYNC_KEY`, and `AUTH_SYNC_PEERS=@peer_contact_bot`. The recommended topology is one contact bot per node; other bots on the same node continue to use local auth mirroring. In multi-bot mode, the first token in `TG_BOT_TOKENS` is the default contact bot. FoxClaw sends encrypted auth bundles through Telegram Bot-to-Bot private messages. A locally verified refresh is pushed to peers, and a node with a bad local candidate can pull an already-held valid peer copy. The contact bot private chat reports send, receive, queue, import, failure, and manual-intervention states. Cross-node recovery never auto-refreshes tokens; `/auth refresh all confirm` first requests a cross-node refresh lease. For full config, `@BotFather` operations, and verification steps, read the [Cross-Node Auth Sync Setup Guide](./docs/cross-node-auth-sync.md).
|
|
218
|
+
When multiple machines share the same legally owned account pool, optional cross-node auth sync can be enabled with `AUTH_SYNC_ENABLED=true`, `AUTH_SYNC_KEY`, and `AUTH_SYNC_PEERS=@peer_contact_bot`. The recommended topology is one contact bot per node; other bots on the same node continue to use local auth mirroring. In multi-bot mode, the first token in `TG_BOT_TOKENS` is the default contact bot. FoxClaw sends encrypted auth bundles through Telegram Bot-to-Bot private messages. A locally verified refresh is pushed to peers, and a node with a bad local candidate can pull an already-held valid peer copy. The contact bot private chat reports send, receive, queue, import, failure, and manual-intervention states. `/auth sync events [filter]` and `/auth sync trace <requestId>` inspect recent traffic. Cross-node recovery never auto-refreshes tokens; `/auth refresh all confirm` first requests a cross-node refresh lease. For full config, `@BotFather` operations, and verification steps, read the [Cross-Node Auth Sync Setup Guide](./docs/cross-node-auth-sync.md).
|
|
219
219
|
|
|
220
220
|
To keep one Telegram bot interoperable with terminal Codex sessions, put the same token in both `TG_BOT_TOKENS` and `TG_BOT_TOKEN`. That bot uses the default `CODEX_HOME` (usually `~/.codex` when unset) and default auth, so it can see local terminal sessions. It no longer has the isolated runtime guarantee: `/auth` switches also affect the terminal and other default runtimes.
|
|
221
221
|
|
|
@@ -31,6 +31,8 @@ export interface AuthSyncStatus {
|
|
|
31
31
|
lastError: string | null;
|
|
32
32
|
candidateFailures: AuthSyncCandidateFailure[];
|
|
33
33
|
activeLeaseId: string | null;
|
|
34
|
+
peerStatuses: AuthSyncPeerStatus[];
|
|
35
|
+
recentEvents: AuthSyncEventRecord[];
|
|
34
36
|
}
|
|
35
37
|
export interface AuthSyncCandidateFailure {
|
|
36
38
|
candidateName: string;
|
|
@@ -41,6 +43,21 @@ export interface AuthSyncCandidateFailure {
|
|
|
41
43
|
mode: AuthSyncRemoteImportMode;
|
|
42
44
|
updatedAt: string;
|
|
43
45
|
}
|
|
46
|
+
export interface AuthSyncPeerStatus {
|
|
47
|
+
peer: string;
|
|
48
|
+
lastReceivedAt: string | null;
|
|
49
|
+
}
|
|
50
|
+
export interface AuthSyncEventRecord {
|
|
51
|
+
id: string;
|
|
52
|
+
createdAt: string;
|
|
53
|
+
direction: 'in' | 'out' | 'local';
|
|
54
|
+
kind: string;
|
|
55
|
+
stage: string;
|
|
56
|
+
peer: string | null;
|
|
57
|
+
requestId: string | null;
|
|
58
|
+
candidateName: string | null;
|
|
59
|
+
detail: string | null;
|
|
60
|
+
}
|
|
44
61
|
export interface AuthSyncValidationResult {
|
|
45
62
|
ok: boolean;
|
|
46
63
|
reason?: string | null;
|
|
@@ -240,6 +257,7 @@ export declare class CrossNodeAuthSync {
|
|
|
240
257
|
private recordCandidateFailure;
|
|
241
258
|
private clearCandidateFailure;
|
|
242
259
|
private notePeerActivity;
|
|
260
|
+
private recordEvent;
|
|
243
261
|
private describePeerReachability;
|
|
244
262
|
private notify;
|
|
245
263
|
private notifyError;
|
|
@@ -10,6 +10,7 @@ const TEST_TIMEOUT_MS = 8_000;
|
|
|
10
10
|
const LEASE_TIMEOUT_MS = 8_000;
|
|
11
11
|
const LEASE_TTL_MS = 10 * 60_000;
|
|
12
12
|
const REMOTE_ACCESS_TOKEN_MIN_TTL_MS = 60_000;
|
|
13
|
+
const RECENT_EVENT_LIMIT = 120;
|
|
13
14
|
export class CrossNodeAuthSync {
|
|
14
15
|
config;
|
|
15
16
|
logger;
|
|
@@ -39,6 +40,7 @@ export class CrossNodeAuthSync {
|
|
|
39
40
|
lastPullCandidate: null,
|
|
40
41
|
lastError: null,
|
|
41
42
|
lastCandidateFailures: {},
|
|
43
|
+
recentEvents: [],
|
|
42
44
|
};
|
|
43
45
|
constructor(config, logger, transport, callbacks) {
|
|
44
46
|
this.config = config;
|
|
@@ -67,6 +69,7 @@ export class CrossNodeAuthSync {
|
|
|
67
69
|
lastPullCandidate: stored.lastPullCandidate ?? null,
|
|
68
70
|
lastError: stored.lastError ?? null,
|
|
69
71
|
lastCandidateFailures: normalizeCandidateFailures(stored.lastCandidateFailures ?? {}),
|
|
72
|
+
recentEvents: normalizeRecentEvents(stored.recentEvents ?? []),
|
|
70
73
|
};
|
|
71
74
|
await fs.mkdir(this.config.tempDir, { recursive: true, mode: 0o700 });
|
|
72
75
|
await this.writeState();
|
|
@@ -109,6 +112,14 @@ export class CrossNodeAuthSync {
|
|
|
109
112
|
candidateFailures: Object.values(this.state.lastCandidateFailures)
|
|
110
113
|
.sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)),
|
|
111
114
|
activeLeaseId: this.activeLocalLease?.leaseId ?? this.activeRemoteLease?.leaseId ?? null,
|
|
115
|
+
peerStatuses: this.peers.map((peer) => {
|
|
116
|
+
const lastActivityAt = this.lastPeerActivityAt.get(peer) ?? null;
|
|
117
|
+
return {
|
|
118
|
+
peer,
|
|
119
|
+
lastReceivedAt: lastActivityAt === null ? null : new Date(lastActivityAt).toISOString(),
|
|
120
|
+
};
|
|
121
|
+
}),
|
|
122
|
+
recentEvents: [...this.state.recentEvents],
|
|
112
123
|
};
|
|
113
124
|
}
|
|
114
125
|
isIdle() {
|
|
@@ -126,16 +137,29 @@ export class CrossNodeAuthSync {
|
|
|
126
137
|
const record = await this.callbacks.readLocalCandidate(candidateName);
|
|
127
138
|
if (!record)
|
|
128
139
|
return false;
|
|
140
|
+
const requestId = crypto.randomUUID();
|
|
141
|
+
this.recordEvent({
|
|
142
|
+
direction: 'local',
|
|
143
|
+
kind: 'candidate.publish',
|
|
144
|
+
stage: 'started',
|
|
145
|
+
peer: null,
|
|
146
|
+
requestId,
|
|
147
|
+
candidateName,
|
|
148
|
+
detail: `peers=${this.peers.join(', ') || 'none'}`,
|
|
149
|
+
});
|
|
129
150
|
this.notify({ kind: 'candidate_publish_started', candidateName, peers: [...this.peers] });
|
|
130
151
|
try {
|
|
131
152
|
await this.sendToAll({
|
|
132
153
|
kind: 'push.bundle',
|
|
154
|
+
requestId,
|
|
133
155
|
...bundleFromRecord(record),
|
|
134
156
|
});
|
|
157
|
+
this.recordEvent({ direction: 'local', kind: 'candidate.publish', stage: 'completed', peer: null, requestId, candidateName, detail: null });
|
|
135
158
|
this.notify({ kind: 'candidate_publish_completed', candidateName, peers: [...this.peers] });
|
|
136
159
|
}
|
|
137
160
|
catch (error) {
|
|
138
161
|
this.recordError(`candidate publish failed for ${candidateName}: ${formatError(error)}`, false);
|
|
162
|
+
this.recordEvent({ direction: 'local', kind: 'candidate.publish', stage: 'failed', peer: null, requestId, candidateName, detail: formatError(error) });
|
|
139
163
|
this.notify({
|
|
140
164
|
kind: 'candidate_publish_failed',
|
|
141
165
|
candidateName,
|
|
@@ -161,6 +185,7 @@ export class CrossNodeAuthSync {
|
|
|
161
185
|
}
|
|
162
186
|
await this.sendToAll({
|
|
163
187
|
kind: 'push.bundle',
|
|
188
|
+
requestId: crypto.randomUUID(),
|
|
164
189
|
...bundleFromRecord(record),
|
|
165
190
|
});
|
|
166
191
|
sent += 1;
|
|
@@ -213,6 +238,15 @@ export class CrossNodeAuthSync {
|
|
|
213
238
|
waitMs,
|
|
214
239
|
peerReachability,
|
|
215
240
|
});
|
|
241
|
+
this.recordEvent({
|
|
242
|
+
direction: 'local',
|
|
243
|
+
kind: 'pull.request',
|
|
244
|
+
stage: 'timeout',
|
|
245
|
+
peer: null,
|
|
246
|
+
requestId,
|
|
247
|
+
candidateName,
|
|
248
|
+
detail: reason,
|
|
249
|
+
});
|
|
216
250
|
this.notify({
|
|
217
251
|
kind: 'recovery_failed',
|
|
218
252
|
candidateName,
|
|
@@ -247,6 +281,15 @@ export class CrossNodeAuthSync {
|
|
|
247
281
|
this.pendingPulls.delete(requestId);
|
|
248
282
|
this.recordError(`pull request failed: ${formatError(error)}`, false);
|
|
249
283
|
const waitMs = Date.now() - startedAt;
|
|
284
|
+
this.recordEvent({
|
|
285
|
+
direction: 'local',
|
|
286
|
+
kind: 'pull.request',
|
|
287
|
+
stage: 'send_failed',
|
|
288
|
+
peer: null,
|
|
289
|
+
requestId,
|
|
290
|
+
candidateName,
|
|
291
|
+
detail: formatError(error),
|
|
292
|
+
});
|
|
250
293
|
this.notify({
|
|
251
294
|
kind: 'recovery_failed',
|
|
252
295
|
candidateName,
|
|
@@ -272,6 +315,7 @@ export class CrossNodeAuthSync {
|
|
|
272
315
|
if (!this.isReady() || this.peers.length === 0) {
|
|
273
316
|
const leaseId = crypto.randomUUID();
|
|
274
317
|
this.activeLocalLease = { leaseId, expiresAt: Date.now() + LEASE_TTL_MS };
|
|
318
|
+
this.recordEvent({ direction: 'local', kind: 'lease.request', stage: 'granted_local', peer: null, requestId: leaseId, candidateName: null, detail: reason });
|
|
275
319
|
return { ok: true, leaseId };
|
|
276
320
|
}
|
|
277
321
|
if (!this.callbacks.isIdle()) {
|
|
@@ -282,6 +326,15 @@ export class CrossNodeAuthSync {
|
|
|
282
326
|
const result = await new Promise((resolve) => {
|
|
283
327
|
const timer = setTimeout(() => {
|
|
284
328
|
this.pendingLeases.delete(leaseId);
|
|
329
|
+
this.recordEvent({
|
|
330
|
+
direction: 'local',
|
|
331
|
+
kind: 'lease.request',
|
|
332
|
+
stage: 'timeout',
|
|
333
|
+
peer: null,
|
|
334
|
+
requestId: leaseId,
|
|
335
|
+
candidateName: null,
|
|
336
|
+
detail: `timed out waiting for ${this.peers.length} auth sync peer lease grant(s); reason=${reason}`,
|
|
337
|
+
});
|
|
285
338
|
resolve({
|
|
286
339
|
ok: false,
|
|
287
340
|
leaseId: null,
|
|
@@ -299,13 +352,16 @@ export class CrossNodeAuthSync {
|
|
|
299
352
|
void this.sendToAll({ kind: 'lease.request', leaseId, reason, expiresAt }).catch((error) => {
|
|
300
353
|
clearTimeout(timer);
|
|
301
354
|
this.pendingLeases.delete(leaseId);
|
|
355
|
+
this.recordEvent({ direction: 'local', kind: 'lease.request', stage: 'send_failed', peer: null, requestId: leaseId, candidateName: null, detail: formatError(error) });
|
|
302
356
|
resolve({ ok: false, leaseId: null, reason: formatError(error) });
|
|
303
357
|
});
|
|
304
358
|
});
|
|
305
359
|
if (result.ok) {
|
|
306
360
|
this.activeLocalLease = { leaseId, expiresAt };
|
|
361
|
+
this.recordEvent({ direction: 'local', kind: 'lease.request', stage: 'granted', peer: null, requestId: leaseId, candidateName: null, detail: reason });
|
|
307
362
|
}
|
|
308
363
|
else {
|
|
364
|
+
this.recordEvent({ direction: 'local', kind: 'lease.request', stage: 'denied', peer: null, requestId: leaseId, candidateName: null, detail: result.reason ?? reason });
|
|
309
365
|
await this.releaseRefreshLease(leaseId);
|
|
310
366
|
}
|
|
311
367
|
return result;
|
|
@@ -329,6 +385,7 @@ export class CrossNodeAuthSync {
|
|
|
329
385
|
const peers = [...this.peers];
|
|
330
386
|
const resultPromise = new Promise((resolve) => {
|
|
331
387
|
const timer = setTimeout(() => {
|
|
388
|
+
this.recordEvent({ direction: 'local', kind: 'test.ping', stage: 'timeout', peer: null, requestId, candidateName: null, detail: null });
|
|
332
389
|
this.finishPendingTest(requestId);
|
|
333
390
|
}, TEST_TIMEOUT_MS);
|
|
334
391
|
timer.unref();
|
|
@@ -370,8 +427,18 @@ export class CrossNodeAuthSync {
|
|
|
370
427
|
}
|
|
371
428
|
this.seenNonces.set(nonceKey, Date.now());
|
|
372
429
|
this.state.lastReceivedAt = new Date().toISOString();
|
|
430
|
+
const normalizedPeer = normalizePeerIdentity(peer);
|
|
431
|
+
this.recordEvent({
|
|
432
|
+
direction: 'in',
|
|
433
|
+
kind: opened.message.kind,
|
|
434
|
+
stage: 'received',
|
|
435
|
+
peer: normalizedPeer,
|
|
436
|
+
requestId: requestIdFromMessage(opened.message),
|
|
437
|
+
candidateName: candidateNameFromMessage(opened.message),
|
|
438
|
+
detail: `sender=${opened.sender}`,
|
|
439
|
+
});
|
|
373
440
|
await this.writeState();
|
|
374
|
-
this.notePeerActivity(
|
|
441
|
+
this.notePeerActivity(normalizedPeer);
|
|
375
442
|
await this.handleMessage(opened.message, opened.sender, peer);
|
|
376
443
|
return true;
|
|
377
444
|
}
|
|
@@ -411,6 +478,7 @@ export class CrossNodeAuthSync {
|
|
|
411
478
|
case 'lease.release':
|
|
412
479
|
if (this.activeRemoteLease?.leaseId === message.leaseId) {
|
|
413
480
|
this.activeRemoteLease = null;
|
|
481
|
+
this.recordEvent({ direction: 'in', kind: 'lease.release', stage: 'released', peer: normalizePeerIdentity(peer), requestId: message.leaseId, candidateName: null, detail: null });
|
|
414
482
|
}
|
|
415
483
|
return;
|
|
416
484
|
default:
|
|
@@ -429,6 +497,7 @@ export class CrossNodeAuthSync {
|
|
|
429
497
|
const record = await this.callbacks.readLocalCandidate(message.candidateName);
|
|
430
498
|
if (!record) {
|
|
431
499
|
await this.sendToPeer(peer, { kind: 'pull.response', requestId: message.requestId, bundle: null, reason: 'candidate not found' });
|
|
500
|
+
this.recordEvent({ direction: 'local', kind: 'pull.response', stage: 'candidate_not_found', peer, requestId: message.requestId, candidateName: message.candidateName, detail: 'candidate not found' });
|
|
432
501
|
this.notify({
|
|
433
502
|
kind: 'pull_response_sent',
|
|
434
503
|
candidateName: message.candidateName,
|
|
@@ -440,6 +509,7 @@ export class CrossNodeAuthSync {
|
|
|
440
509
|
}
|
|
441
510
|
if (message.accountId && record.accountId !== message.accountId) {
|
|
442
511
|
await this.sendToPeer(peer, { kind: 'pull.response', requestId: message.requestId, bundle: null, reason: 'account mismatch' });
|
|
512
|
+
this.recordEvent({ direction: 'local', kind: 'pull.response', stage: 'account_mismatch', peer, requestId: message.requestId, candidateName: message.candidateName, detail: 'account mismatch' });
|
|
443
513
|
this.notify({
|
|
444
514
|
kind: 'pull_response_sent',
|
|
445
515
|
candidateName: message.candidateName,
|
|
@@ -451,6 +521,7 @@ export class CrossNodeAuthSync {
|
|
|
451
521
|
}
|
|
452
522
|
if (message.lastRefreshMs !== null && record.lastRefreshMs <= message.lastRefreshMs) {
|
|
453
523
|
await this.sendToPeer(peer, { kind: 'pull.response', requestId: message.requestId, bundle: null, reason: 'not newer' });
|
|
524
|
+
this.recordEvent({ direction: 'local', kind: 'pull.response', stage: 'not_newer', peer, requestId: message.requestId, candidateName: message.candidateName, detail: 'not newer' });
|
|
454
525
|
this.notify({
|
|
455
526
|
kind: 'pull_response_sent',
|
|
456
527
|
candidateName: message.candidateName,
|
|
@@ -465,6 +536,7 @@ export class CrossNodeAuthSync {
|
|
|
465
536
|
requestId: message.requestId,
|
|
466
537
|
bundle: bundleFromRecord(record),
|
|
467
538
|
});
|
|
539
|
+
this.recordEvent({ direction: 'local', kind: 'pull.response', stage: 'sent_bundle', peer, requestId: message.requestId, candidateName: message.candidateName, detail: null });
|
|
468
540
|
this.notify({
|
|
469
541
|
kind: 'pull_response_sent',
|
|
470
542
|
candidateName: message.candidateName,
|
|
@@ -480,6 +552,7 @@ export class CrossNodeAuthSync {
|
|
|
480
552
|
const matchedPeer = this.matchConfiguredPeer(peer) ?? peer;
|
|
481
553
|
if (!message.bundle || message.bundle.candidateName !== pending.candidateName) {
|
|
482
554
|
const reason = message.reason ?? 'peer did not return a matching candidate';
|
|
555
|
+
this.recordEvent({ direction: 'local', kind: 'pull.response', stage: 'empty', peer, requestId: message.requestId, candidateName: pending.candidateName, detail: reason });
|
|
483
556
|
this.notify({
|
|
484
557
|
kind: 'recovery_peer_empty',
|
|
485
558
|
candidateName: pending.candidateName,
|
|
@@ -498,12 +571,14 @@ export class CrossNodeAuthSync {
|
|
|
498
571
|
const outcome = await this.validateAndImport(message.bundle, senderNodeId, sourceLabel, peer, 'pull');
|
|
499
572
|
if (!outcome.imported) {
|
|
500
573
|
this.markPullPeerUnavailable(message.requestId, matchedPeer, outcome.reason ?? 'peer candidate was not imported');
|
|
574
|
+
this.recordEvent({ direction: 'local', kind: 'pull.response', stage: 'not_imported', peer, requestId: message.requestId, candidateName: pending.candidateName, detail: outcome.reason ?? 'peer candidate was not imported' });
|
|
501
575
|
return;
|
|
502
576
|
}
|
|
503
577
|
pending.finished = true;
|
|
504
578
|
clearTimeout(pending.timer);
|
|
505
579
|
this.pendingPulls.delete(message.requestId);
|
|
506
580
|
pending.resolve(true);
|
|
581
|
+
this.recordEvent({ direction: 'local', kind: 'pull.response', stage: 'imported', peer, requestId: message.requestId, candidateName: pending.candidateName, detail: null });
|
|
507
582
|
}
|
|
508
583
|
markPullPeerUnavailable(requestId, peer, reason) {
|
|
509
584
|
const pending = this.pendingPulls.get(requestId);
|
|
@@ -525,6 +600,15 @@ export class CrossNodeAuthSync {
|
|
|
525
600
|
peers: pending.peers,
|
|
526
601
|
reason: `all peers replied without an importable auth candidate${details ? ` (${details})` : ''}`,
|
|
527
602
|
});
|
|
603
|
+
this.recordEvent({
|
|
604
|
+
direction: 'local',
|
|
605
|
+
kind: 'pull.request',
|
|
606
|
+
stage: 'failed',
|
|
607
|
+
peer: null,
|
|
608
|
+
requestId,
|
|
609
|
+
candidateName: pending.candidateName,
|
|
610
|
+
detail: `all peers replied without an importable auth candidate${details ? ` (${details})` : ''}`,
|
|
611
|
+
});
|
|
528
612
|
pending.resolve(false);
|
|
529
613
|
}
|
|
530
614
|
async handleDigest(message, peer) {
|
|
@@ -546,10 +630,12 @@ export class CrossNodeAuthSync {
|
|
|
546
630
|
this.expireLeases();
|
|
547
631
|
if (!this.callbacks.isIdle()) {
|
|
548
632
|
await this.sendToPeer(peer, { kind: 'lease.deny', leaseId: message.leaseId, reason: 'runtime is not idle' });
|
|
633
|
+
this.recordEvent({ direction: 'local', kind: 'lease.deny', stage: 'sent', peer, requestId: message.leaseId, candidateName: null, detail: 'runtime is not idle' });
|
|
549
634
|
return;
|
|
550
635
|
}
|
|
551
636
|
if (this.activeRemoteLease && this.activeRemoteLease.leaseId !== message.leaseId) {
|
|
552
637
|
await this.sendToPeer(peer, { kind: 'lease.deny', leaseId: message.leaseId, reason: 'another refresh lease is active' });
|
|
638
|
+
this.recordEvent({ direction: 'local', kind: 'lease.deny', stage: 'sent', peer, requestId: message.leaseId, candidateName: null, detail: 'another refresh lease is active' });
|
|
553
639
|
return;
|
|
554
640
|
}
|
|
555
641
|
this.activeRemoteLease = {
|
|
@@ -558,6 +644,7 @@ export class CrossNodeAuthSync {
|
|
|
558
644
|
expiresAt: Math.min(message.expiresAt, Date.now() + LEASE_TTL_MS),
|
|
559
645
|
};
|
|
560
646
|
await this.sendToPeer(peer, { kind: 'lease.grant', leaseId: message.leaseId, expiresAt: this.activeRemoteLease.expiresAt });
|
|
647
|
+
this.recordEvent({ direction: 'local', kind: 'lease.grant', stage: 'sent', peer, requestId: message.leaseId, candidateName: null, detail: null });
|
|
561
648
|
}
|
|
562
649
|
handleLeaseReply(message, peer) {
|
|
563
650
|
const pending = this.pendingLeases.get(message.leaseId);
|
|
@@ -565,9 +652,11 @@ export class CrossNodeAuthSync {
|
|
|
565
652
|
return;
|
|
566
653
|
if (message.kind === 'lease.deny') {
|
|
567
654
|
pending.denies.push(`${peer}: ${message.reason}`);
|
|
655
|
+
this.recordEvent({ direction: 'in', kind: 'lease.deny', stage: 'received', peer, requestId: message.leaseId, candidateName: null, detail: message.reason });
|
|
568
656
|
}
|
|
569
657
|
else {
|
|
570
658
|
pending.grants.add(peer);
|
|
659
|
+
this.recordEvent({ direction: 'in', kind: 'lease.grant', stage: 'received', peer, requestId: message.leaseId, candidateName: null, detail: null });
|
|
571
660
|
}
|
|
572
661
|
if (pending.denies.length > 0) {
|
|
573
662
|
clearTimeout(pending.timer);
|
|
@@ -590,6 +679,15 @@ export class CrossNodeAuthSync {
|
|
|
590
679
|
receivedAt: Date.now(),
|
|
591
680
|
fromPeer,
|
|
592
681
|
});
|
|
682
|
+
this.recordEvent({
|
|
683
|
+
direction: 'local',
|
|
684
|
+
kind: 'push.bundle',
|
|
685
|
+
stage: queued ? 'queued' : 'processing',
|
|
686
|
+
peer: fromPeer,
|
|
687
|
+
requestId: bundle.requestId ?? null,
|
|
688
|
+
candidateName: bundle.candidateName,
|
|
689
|
+
detail: `source=${sourceNodeId}; queue=${this.pendingImports.length}`,
|
|
690
|
+
});
|
|
593
691
|
this.notify({
|
|
594
692
|
kind: 'remote_bundle_received',
|
|
595
693
|
candidateName: bundle.candidateName,
|
|
@@ -655,6 +753,15 @@ export class CrossNodeAuthSync {
|
|
|
655
753
|
this.state.lastError = null;
|
|
656
754
|
await this.writeState();
|
|
657
755
|
this.logger.info('auth.sync.imported', { candidateName: bundle.candidateName, sourceNodeId });
|
|
756
|
+
this.recordEvent({
|
|
757
|
+
direction: 'local',
|
|
758
|
+
kind: mode === 'pull' ? 'pull.response' : 'push.bundle',
|
|
759
|
+
stage: 'imported',
|
|
760
|
+
peer: fromPeer,
|
|
761
|
+
requestId: bundle.requestId ?? null,
|
|
762
|
+
candidateName: bundle.candidateName,
|
|
763
|
+
detail: `source=${sourceNodeId}`,
|
|
764
|
+
});
|
|
658
765
|
this.notify({
|
|
659
766
|
kind: 'remote_import_imported',
|
|
660
767
|
candidateName: bundle.candidateName,
|
|
@@ -665,6 +772,15 @@ export class CrossNodeAuthSync {
|
|
|
665
772
|
});
|
|
666
773
|
}
|
|
667
774
|
else {
|
|
775
|
+
this.recordEvent({
|
|
776
|
+
direction: 'local',
|
|
777
|
+
kind: mode === 'pull' ? 'pull.response' : 'push.bundle',
|
|
778
|
+
stage: 'skipped',
|
|
779
|
+
peer: fromPeer,
|
|
780
|
+
requestId: bundle.requestId ?? null,
|
|
781
|
+
candidateName: bundle.candidateName,
|
|
782
|
+
detail: result.reason ?? 'local candidate did not need an update',
|
|
783
|
+
});
|
|
668
784
|
this.notify({
|
|
669
785
|
kind: 'remote_import_skipped',
|
|
670
786
|
candidateName: bundle.candidateName,
|
|
@@ -685,7 +801,30 @@ export class CrossNodeAuthSync {
|
|
|
685
801
|
}
|
|
686
802
|
async sendToPeer(peer, message) {
|
|
687
803
|
const envelope = this.sealEnvelope(message);
|
|
688
|
-
|
|
804
|
+
try {
|
|
805
|
+
await this.transport.send(peer, envelope);
|
|
806
|
+
this.recordEvent({
|
|
807
|
+
direction: 'out',
|
|
808
|
+
kind: message.kind,
|
|
809
|
+
stage: 'sent',
|
|
810
|
+
peer,
|
|
811
|
+
requestId: requestIdFromMessage(message),
|
|
812
|
+
candidateName: candidateNameFromMessage(message),
|
|
813
|
+
detail: null,
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
catch (error) {
|
|
817
|
+
this.recordEvent({
|
|
818
|
+
direction: 'out',
|
|
819
|
+
kind: message.kind,
|
|
820
|
+
stage: 'send_failed',
|
|
821
|
+
peer,
|
|
822
|
+
requestId: requestIdFromMessage(message),
|
|
823
|
+
candidateName: candidateNameFromMessage(message),
|
|
824
|
+
detail: formatError(error),
|
|
825
|
+
});
|
|
826
|
+
throw error;
|
|
827
|
+
}
|
|
689
828
|
this.state.lastSentAt = new Date().toISOString();
|
|
690
829
|
if (this.state.lastError?.includes('USER_BOT_TO_BOT_DISABLED')) {
|
|
691
830
|
this.state.lastError = null;
|
|
@@ -698,6 +837,7 @@ export class CrossNodeAuthSync {
|
|
|
698
837
|
return;
|
|
699
838
|
const matchedPeer = this.matchConfiguredPeer(peer) ?? peer;
|
|
700
839
|
pending.replies.add(matchedPeer);
|
|
840
|
+
this.recordEvent({ direction: 'local', kind: 'test.pong', stage: 'matched', peer: matchedPeer, requestId, candidateName: null, detail: null });
|
|
701
841
|
if (pending.peers.every(peerName => pending.replies.has(peerName))) {
|
|
702
842
|
this.finishPendingTest(requestId);
|
|
703
843
|
}
|
|
@@ -804,6 +944,15 @@ export class CrossNodeAuthSync {
|
|
|
804
944
|
}
|
|
805
945
|
}
|
|
806
946
|
async rejectImport(bundle, sourceNodeId, sourceLabel, fromPeer, mode, reason) {
|
|
947
|
+
this.recordEvent({
|
|
948
|
+
direction: 'local',
|
|
949
|
+
kind: mode === 'pull' ? 'pull.response' : 'push.bundle',
|
|
950
|
+
stage: 'failed',
|
|
951
|
+
peer: fromPeer,
|
|
952
|
+
requestId: bundle.requestId ?? null,
|
|
953
|
+
candidateName: typeof bundle.candidateName === 'string' ? bundle.candidateName : null,
|
|
954
|
+
detail: reason,
|
|
955
|
+
});
|
|
807
956
|
await this.recordCandidateFailure(bundle, sourceNodeId, sourceLabel, fromPeer, mode, reason);
|
|
808
957
|
this.notify({
|
|
809
958
|
kind: 'remote_import_failed',
|
|
@@ -851,6 +1000,24 @@ export class CrossNodeAuthSync {
|
|
|
851
1000
|
const matchedPeer = this.matchConfiguredPeer(peer) ?? peer;
|
|
852
1001
|
this.lastPeerActivityAt.set(matchedPeer, Date.now());
|
|
853
1002
|
}
|
|
1003
|
+
recordEvent(event) {
|
|
1004
|
+
const record = {
|
|
1005
|
+
id: crypto.randomBytes(6).toString('hex'),
|
|
1006
|
+
createdAt: new Date().toISOString(),
|
|
1007
|
+
direction: event.direction,
|
|
1008
|
+
kind: event.kind,
|
|
1009
|
+
stage: event.stage,
|
|
1010
|
+
peer: event.peer,
|
|
1011
|
+
requestId: event.requestId,
|
|
1012
|
+
candidateName: event.candidateName,
|
|
1013
|
+
detail: event.detail,
|
|
1014
|
+
};
|
|
1015
|
+
this.state.recentEvents = pruneRecentEvents([...this.state.recentEvents, record]);
|
|
1016
|
+
this.logger.info('auth.sync.event', record);
|
|
1017
|
+
void this.writeState().catch((error) => {
|
|
1018
|
+
this.logger.warn('auth.sync.state_write_failed', { error: formatError(error) });
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
854
1021
|
describePeerReachability(peers, sinceMs) {
|
|
855
1022
|
return peers.map((peer) => {
|
|
856
1023
|
const lastActivityAt = this.lastPeerActivityAt.get(peer) ?? null;
|
|
@@ -879,7 +1046,7 @@ export class CrossNodeAuthSync {
|
|
|
879
1046
|
return;
|
|
880
1047
|
this.seenNonces = pruneSeenNonces(this.seenNonces);
|
|
881
1048
|
await fs.mkdir(path.dirname(this.config.statePath), { recursive: true, mode: 0o700 });
|
|
882
|
-
const temporary = `${this.config.statePath}.${process.pid}.${Date.now()}.tmp`;
|
|
1049
|
+
const temporary = `${this.config.statePath}.${process.pid}.${Date.now()}.${crypto.randomBytes(3).toString('hex')}.tmp`;
|
|
883
1050
|
const state = {
|
|
884
1051
|
seenNonces: Object.fromEntries(this.seenNonces),
|
|
885
1052
|
...this.state,
|
|
@@ -998,6 +1165,56 @@ function pruneCandidateFailures(failures) {
|
|
|
998
1165
|
.sort(([, left], [, right]) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt))
|
|
999
1166
|
.slice(0, 20));
|
|
1000
1167
|
}
|
|
1168
|
+
function normalizeRecentEvents(raw) {
|
|
1169
|
+
if (!Array.isArray(raw))
|
|
1170
|
+
return [];
|
|
1171
|
+
return pruneRecentEvents(raw.filter((event) => (event
|
|
1172
|
+
&& typeof event.id === 'string'
|
|
1173
|
+
&& typeof event.createdAt === 'string'
|
|
1174
|
+
&& Number.isFinite(Date.parse(event.createdAt))
|
|
1175
|
+
&& (event.direction === 'in' || event.direction === 'out' || event.direction === 'local')
|
|
1176
|
+
&& typeof event.kind === 'string'
|
|
1177
|
+
&& typeof event.stage === 'string'
|
|
1178
|
+
&& (typeof event.peer === 'string' || event.peer === null)
|
|
1179
|
+
&& (typeof event.requestId === 'string' || event.requestId === null)
|
|
1180
|
+
&& (typeof event.candidateName === 'string' || event.candidateName === null)
|
|
1181
|
+
&& (typeof event.detail === 'string' || event.detail === null))));
|
|
1182
|
+
}
|
|
1183
|
+
function pruneRecentEvents(events) {
|
|
1184
|
+
return events
|
|
1185
|
+
.sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt))
|
|
1186
|
+
.slice(-RECENT_EVENT_LIMIT);
|
|
1187
|
+
}
|
|
1188
|
+
function requestIdFromMessage(message) {
|
|
1189
|
+
switch (message.kind) {
|
|
1190
|
+
case 'push.bundle':
|
|
1191
|
+
return message.requestId ?? null;
|
|
1192
|
+
case 'pull.request':
|
|
1193
|
+
case 'pull.response':
|
|
1194
|
+
case 'test.ping':
|
|
1195
|
+
case 'test.pong':
|
|
1196
|
+
return message.requestId;
|
|
1197
|
+
case 'lease.request':
|
|
1198
|
+
return message.leaseId;
|
|
1199
|
+
case 'lease.grant':
|
|
1200
|
+
case 'lease.deny':
|
|
1201
|
+
case 'lease.release':
|
|
1202
|
+
return message.leaseId;
|
|
1203
|
+
case 'digest':
|
|
1204
|
+
return null;
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
function candidateNameFromMessage(message) {
|
|
1208
|
+
switch (message.kind) {
|
|
1209
|
+
case 'push.bundle':
|
|
1210
|
+
case 'pull.request':
|
|
1211
|
+
return message.candidateName;
|
|
1212
|
+
case 'pull.response':
|
|
1213
|
+
return message.bundle?.candidateName ?? null;
|
|
1214
|
+
default:
|
|
1215
|
+
return null;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1001
1218
|
function formatPullTimeoutReason(pending, waitMs, peerReachability) {
|
|
1002
1219
|
const reachable = peerReachability
|
|
1003
1220
|
.filter((entry) => entry.reachableDuringRequest)
|
|
@@ -1671,9 +1671,15 @@ export class BridgeSessionCore {
|
|
|
1671
1671
|
await this.sendMessage(scopeId, lines.join('\n'));
|
|
1672
1672
|
return;
|
|
1673
1673
|
}
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1674
|
+
if (!success) {
|
|
1675
|
+
await this.sendMessage(scopeId, t(locale, 'login_failed', { error: params?.error ?? t(locale, 'unknown') }));
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
const currentCandidate = (await this.listCodexAuthState()).candidates.find(candidate => candidate.isCurrent) ?? null;
|
|
1679
|
+
if (currentCandidate) {
|
|
1680
|
+
await this.syncCodexAuthCandidate(currentCandidate.name);
|
|
1681
|
+
}
|
|
1682
|
+
await this.sendMessage(scopeId, t(locale, 'login_completed'));
|
|
1677
1683
|
}
|
|
1678
1684
|
async restorePendingAuthAdd(record) {
|
|
1679
1685
|
const state = await this.listCodexAuthState();
|
|
@@ -4095,6 +4101,19 @@ export class BridgeSessionCore {
|
|
|
4095
4101
|
await this.sendMessage(scopeId, formatAuthSyncStatus(locale, this.coordinator?.getAuthSyncStatus?.() ?? null));
|
|
4096
4102
|
return;
|
|
4097
4103
|
}
|
|
4104
|
+
if (action === 'events') {
|
|
4105
|
+
await this.sendMessage(scopeId, formatAuthSyncEvents(locale, this.coordinator?.getAuthSyncStatus?.() ?? null, args.slice(1).join(' ').trim() || null));
|
|
4106
|
+
return;
|
|
4107
|
+
}
|
|
4108
|
+
if (action === 'trace') {
|
|
4109
|
+
const requestId = args[1]?.trim() || null;
|
|
4110
|
+
if (!requestId) {
|
|
4111
|
+
await this.sendMessage(scopeId, t(locale, 'auth_sync_trace_missing'));
|
|
4112
|
+
return;
|
|
4113
|
+
}
|
|
4114
|
+
await this.sendMessage(scopeId, formatAuthSyncTrace(locale, this.coordinator?.getAuthSyncStatus?.() ?? null, requestId));
|
|
4115
|
+
return;
|
|
4116
|
+
}
|
|
4098
4117
|
if (action === 'test') {
|
|
4099
4118
|
const result = await this.coordinator?.authSyncTest?.();
|
|
4100
4119
|
if (!result) {
|
|
@@ -8243,6 +8262,15 @@ function formatAuthSyncStatus(locale, status) {
|
|
|
8243
8262
|
if (status.lastError) {
|
|
8244
8263
|
lines.push(t(locale, 'auth_sync_status_error', { value: status.lastError }));
|
|
8245
8264
|
}
|
|
8265
|
+
if (status.peerStatuses?.length) {
|
|
8266
|
+
lines.push(t(locale, 'auth_sync_status_peer_activity'));
|
|
8267
|
+
for (const peer of status.peerStatuses.slice(0, 5)) {
|
|
8268
|
+
lines.push(t(locale, 'auth_sync_status_peer_activity_item', {
|
|
8269
|
+
peer: peer.peer,
|
|
8270
|
+
time: peer.lastReceivedAt ?? t(locale, 'none'),
|
|
8271
|
+
}));
|
|
8272
|
+
}
|
|
8273
|
+
}
|
|
8246
8274
|
if (status.candidateFailures?.length) {
|
|
8247
8275
|
lines.push(t(locale, 'auth_sync_status_candidate_failures'));
|
|
8248
8276
|
for (const failure of status.candidateFailures.slice(0, 5)) {
|
|
@@ -8255,8 +8283,77 @@ function formatAuthSyncStatus(locale, status) {
|
|
|
8255
8283
|
}));
|
|
8256
8284
|
}
|
|
8257
8285
|
}
|
|
8286
|
+
const recentEvents = [...(status.recentEvents ?? [])].slice(-5);
|
|
8287
|
+
if (recentEvents.length > 0) {
|
|
8288
|
+
lines.push(t(locale, 'auth_sync_status_recent_events'));
|
|
8289
|
+
for (const event of recentEvents) {
|
|
8290
|
+
lines.push(formatAuthSyncEventLine(event));
|
|
8291
|
+
}
|
|
8292
|
+
}
|
|
8258
8293
|
return lines.join('\n');
|
|
8259
8294
|
}
|
|
8295
|
+
function formatAuthSyncEvents(locale, status, filter) {
|
|
8296
|
+
if (!status?.enabled) {
|
|
8297
|
+
return t(locale, 'auth_sync_disabled');
|
|
8298
|
+
}
|
|
8299
|
+
const events = filterAuthSyncEvents(status.recentEvents ?? [], filter).slice(-15);
|
|
8300
|
+
if (events.length === 0) {
|
|
8301
|
+
return [
|
|
8302
|
+
t(locale, 'auth_sync_events_title'),
|
|
8303
|
+
t(locale, 'auth_sync_events_empty'),
|
|
8304
|
+
].join('\n');
|
|
8305
|
+
}
|
|
8306
|
+
return [
|
|
8307
|
+
t(locale, 'auth_sync_events_title'),
|
|
8308
|
+
...events.map(formatAuthSyncEventLine),
|
|
8309
|
+
].join('\n');
|
|
8310
|
+
}
|
|
8311
|
+
function formatAuthSyncTrace(locale, status, requestId) {
|
|
8312
|
+
if (!status?.enabled) {
|
|
8313
|
+
return t(locale, 'auth_sync_disabled');
|
|
8314
|
+
}
|
|
8315
|
+
const events = (status.recentEvents ?? [])
|
|
8316
|
+
.filter(event => event.requestId === requestId || event.id === requestId)
|
|
8317
|
+
.slice(-25);
|
|
8318
|
+
if (events.length === 0) {
|
|
8319
|
+
return [
|
|
8320
|
+
t(locale, 'auth_sync_trace_title', { value: requestId }),
|
|
8321
|
+
t(locale, 'auth_sync_events_empty'),
|
|
8322
|
+
].join('\n');
|
|
8323
|
+
}
|
|
8324
|
+
return [
|
|
8325
|
+
t(locale, 'auth_sync_trace_title', { value: requestId }),
|
|
8326
|
+
...events.map(formatAuthSyncEventLine),
|
|
8327
|
+
].join('\n');
|
|
8328
|
+
}
|
|
8329
|
+
function filterAuthSyncEvents(events, filter) {
|
|
8330
|
+
const normalized = filter?.trim().toLowerCase() ?? '';
|
|
8331
|
+
if (!normalized) {
|
|
8332
|
+
return events;
|
|
8333
|
+
}
|
|
8334
|
+
return events.filter((event) => [
|
|
8335
|
+
event.id,
|
|
8336
|
+
event.requestId,
|
|
8337
|
+
event.candidateName,
|
|
8338
|
+
event.peer,
|
|
8339
|
+
event.kind,
|
|
8340
|
+
event.stage,
|
|
8341
|
+
event.detail,
|
|
8342
|
+
].some(value => value?.toLowerCase().includes(normalized)));
|
|
8343
|
+
}
|
|
8344
|
+
function formatAuthSyncEventLine(event) {
|
|
8345
|
+
const fields = [
|
|
8346
|
+
event.createdAt,
|
|
8347
|
+
event.direction,
|
|
8348
|
+
event.kind,
|
|
8349
|
+
event.stage,
|
|
8350
|
+
event.peer ? `peer=${event.peer}` : null,
|
|
8351
|
+
event.requestId ? `requestId=${event.requestId}` : null,
|
|
8352
|
+
event.candidateName ? `candidate=${event.candidateName}` : null,
|
|
8353
|
+
event.detail ? truncateInline(event.detail, 160) : null,
|
|
8354
|
+
].filter(Boolean);
|
|
8355
|
+
return `- ${fields.join(' | ')}`;
|
|
8356
|
+
}
|
|
8260
8357
|
function normalizeHelpUsageKey(name) {
|
|
8261
8358
|
const normalized = name.toLowerCase();
|
|
8262
8359
|
switch (normalized) {
|
package/dist/i18n.d.ts
CHANGED
|
@@ -142,7 +142,7 @@ declare const MESSAGES: {
|
|
|
142
142
|
readonly auth_reload_done: "Codex app-server restarted. Current auth has been reloaded.";
|
|
143
143
|
readonly auth_reload_blocked_active: "Cannot reload Codex auth while a turn, approval, or question is active. Wait or use /interrupt first.";
|
|
144
144
|
readonly usage_auth: "Usage: /auth [list [keyword]|filter <all|enabled|attention>|page <n>|use <n>|enable <n>|disable <n>|reload|refresh all [confirm]|sync <status|test|push all>|add <name>]";
|
|
145
|
-
readonly usage_auth_sync: "Usage: /auth sync <status|test|push all>";
|
|
145
|
+
readonly usage_auth_sync: "Usage: /auth sync <status|events [filter]|trace <requestId>|test|push all>";
|
|
146
146
|
readonly usage_auth_add: "Usage: /auth add <name>. Use letters, numbers, dot, dash, or underscore.";
|
|
147
147
|
readonly auth_list_title: "Codex auth files:";
|
|
148
148
|
readonly auth_bot: "Bot runtime: {value}";
|
|
@@ -224,6 +224,13 @@ declare const MESSAGES: {
|
|
|
224
224
|
readonly auth_sync_status_error: "Last error: {value}";
|
|
225
225
|
readonly auth_sync_status_candidate_failures: "Candidate failures:";
|
|
226
226
|
readonly auth_sync_status_candidate_failure: "- {candidate}: {reason} (source {source}, peer {peer}, at {time})";
|
|
227
|
+
readonly auth_sync_status_peer_activity: "Peer activity:";
|
|
228
|
+
readonly auth_sync_status_peer_activity_item: "- {peer}: last received {time}";
|
|
229
|
+
readonly auth_sync_status_recent_events: "Recent events:";
|
|
230
|
+
readonly auth_sync_events_title: "Auth sync events:";
|
|
231
|
+
readonly auth_sync_events_empty: "No matching auth sync events.";
|
|
232
|
+
readonly auth_sync_trace_title: "Auth sync trace: {value}";
|
|
233
|
+
readonly auth_sync_trace_missing: "Usage: /auth sync trace <requestId>";
|
|
227
234
|
readonly button_login_device: "🔑 Login";
|
|
228
235
|
readonly button_auth_reload: "🔄 Reload auth";
|
|
229
236
|
readonly button_auth_refresh_all_confirm: "⚠️ Accept risk & refresh";
|
|
@@ -776,7 +783,7 @@ declare const MESSAGES: {
|
|
|
776
783
|
readonly auth_reload_done: "Codex app-server 已重启,当前 auth 已重新读取。";
|
|
777
784
|
readonly auth_reload_blocked_active: "当前有回复、审批或问题在进行中,不能重载 Codex auth。请先等待,或使用 /interrupt。";
|
|
778
785
|
readonly usage_auth: "用法:/auth [list [关键词]|filter <all|enabled|attention>|page <页码>|use <编号>|enable <编号>|disable <编号>|reload|refresh all [confirm]|sync <status|test|push all>|add <名称>]";
|
|
779
|
-
readonly usage_auth_sync: "用法:/auth sync <status|test|push all>";
|
|
786
|
+
readonly usage_auth_sync: "用法:/auth sync <status|events [过滤]|trace <requestId>|test|push all>";
|
|
780
787
|
readonly usage_auth_add: "用法:/auth add <名称>。名称只能包含字母、数字、点、短横线或下划线。";
|
|
781
788
|
readonly auth_list_title: "Codex auth 文件:";
|
|
782
789
|
readonly auth_bot: "Bot runtime:{value}";
|
|
@@ -858,6 +865,13 @@ declare const MESSAGES: {
|
|
|
858
865
|
readonly auth_sync_status_error: "最近错误:{value}";
|
|
859
866
|
readonly auth_sync_status_candidate_failures: "候选失败:";
|
|
860
867
|
readonly auth_sync_status_candidate_failure: "- {candidate}:{reason}(来源 {source},peer {peer},时间 {time})";
|
|
868
|
+
readonly auth_sync_status_peer_activity: "Peer 活动:";
|
|
869
|
+
readonly auth_sync_status_peer_activity_item: "- {peer}:最近接收 {time}";
|
|
870
|
+
readonly auth_sync_status_recent_events: "最近事件:";
|
|
871
|
+
readonly auth_sync_events_title: "auth sync 事件:";
|
|
872
|
+
readonly auth_sync_events_empty: "没有匹配的 auth sync 事件。";
|
|
873
|
+
readonly auth_sync_trace_title: "auth sync trace:{value}";
|
|
874
|
+
readonly auth_sync_trace_missing: "用法:/auth sync trace <requestId>";
|
|
861
875
|
readonly button_login_device: "🔑 设备登录";
|
|
862
876
|
readonly button_auth_reload: "🔄 重载 auth";
|
|
863
877
|
readonly button_auth_refresh_all_confirm: "⚠️ 接受风险并刷新";
|
package/dist/i18n.js
CHANGED
|
@@ -140,7 +140,7 @@ const MESSAGES = {
|
|
|
140
140
|
auth_reload_done: 'Codex app-server restarted. Current auth has been reloaded.',
|
|
141
141
|
auth_reload_blocked_active: 'Cannot reload Codex auth while a turn, approval, or question is active. Wait or use /interrupt first.',
|
|
142
142
|
usage_auth: 'Usage: /auth [list [keyword]|filter <all|enabled|attention>|page <n>|use <n>|enable <n>|disable <n>|reload|refresh all [confirm]|sync <status|test|push all>|add <name>]',
|
|
143
|
-
usage_auth_sync: 'Usage: /auth sync <status|test|push all>',
|
|
143
|
+
usage_auth_sync: 'Usage: /auth sync <status|events [filter]|trace <requestId>|test|push all>',
|
|
144
144
|
usage_auth_add: 'Usage: /auth add <name>. Use letters, numbers, dot, dash, or underscore.',
|
|
145
145
|
auth_list_title: 'Codex auth files:',
|
|
146
146
|
auth_bot: 'Bot runtime: {value}',
|
|
@@ -222,6 +222,13 @@ const MESSAGES = {
|
|
|
222
222
|
auth_sync_status_error: 'Last error: {value}',
|
|
223
223
|
auth_sync_status_candidate_failures: 'Candidate failures:',
|
|
224
224
|
auth_sync_status_candidate_failure: '- {candidate}: {reason} (source {source}, peer {peer}, at {time})',
|
|
225
|
+
auth_sync_status_peer_activity: 'Peer activity:',
|
|
226
|
+
auth_sync_status_peer_activity_item: '- {peer}: last received {time}',
|
|
227
|
+
auth_sync_status_recent_events: 'Recent events:',
|
|
228
|
+
auth_sync_events_title: 'Auth sync events:',
|
|
229
|
+
auth_sync_events_empty: 'No matching auth sync events.',
|
|
230
|
+
auth_sync_trace_title: 'Auth sync trace: {value}',
|
|
231
|
+
auth_sync_trace_missing: 'Usage: /auth sync trace <requestId>',
|
|
225
232
|
button_login_device: '🔑 Login',
|
|
226
233
|
button_auth_reload: '🔄 Reload auth',
|
|
227
234
|
button_auth_refresh_all_confirm: '⚠️ Accept risk & refresh',
|
|
@@ -774,7 +781,7 @@ const MESSAGES = {
|
|
|
774
781
|
auth_reload_done: 'Codex app-server 已重启,当前 auth 已重新读取。',
|
|
775
782
|
auth_reload_blocked_active: '当前有回复、审批或问题在进行中,不能重载 Codex auth。请先等待,或使用 /interrupt。',
|
|
776
783
|
usage_auth: '用法:/auth [list [关键词]|filter <all|enabled|attention>|page <页码>|use <编号>|enable <编号>|disable <编号>|reload|refresh all [confirm]|sync <status|test|push all>|add <名称>]',
|
|
777
|
-
usage_auth_sync: '用法:/auth sync <status|test|push all>',
|
|
784
|
+
usage_auth_sync: '用法:/auth sync <status|events [过滤]|trace <requestId>|test|push all>',
|
|
778
785
|
usage_auth_add: '用法:/auth add <名称>。名称只能包含字母、数字、点、短横线或下划线。',
|
|
779
786
|
auth_list_title: 'Codex auth 文件:',
|
|
780
787
|
auth_bot: 'Bot runtime:{value}',
|
|
@@ -856,6 +863,13 @@ const MESSAGES = {
|
|
|
856
863
|
auth_sync_status_error: '最近错误:{value}',
|
|
857
864
|
auth_sync_status_candidate_failures: '候选失败:',
|
|
858
865
|
auth_sync_status_candidate_failure: '- {candidate}:{reason}(来源 {source},peer {peer},时间 {time})',
|
|
866
|
+
auth_sync_status_peer_activity: 'Peer 活动:',
|
|
867
|
+
auth_sync_status_peer_activity_item: '- {peer}:最近接收 {time}',
|
|
868
|
+
auth_sync_status_recent_events: '最近事件:',
|
|
869
|
+
auth_sync_events_title: 'auth sync 事件:',
|
|
870
|
+
auth_sync_events_empty: '没有匹配的 auth sync 事件。',
|
|
871
|
+
auth_sync_trace_title: 'auth sync trace:{value}',
|
|
872
|
+
auth_sync_trace_missing: '用法:/auth sync trace <requestId>',
|
|
859
873
|
button_login_device: '🔑 设备登录',
|
|
860
874
|
button_auth_reload: '🔄 重载 auth',
|
|
861
875
|
button_auth_refresh_all_confirm: '⚠️ 接受风险并刷新',
|
package/dist/types.d.ts
CHANGED
|
@@ -383,6 +383,21 @@ export interface RuntimeStatus {
|
|
|
383
383
|
updatedAt: string;
|
|
384
384
|
}>;
|
|
385
385
|
activeLeaseId: string | null;
|
|
386
|
+
peerStatuses?: Array<{
|
|
387
|
+
peer: string;
|
|
388
|
+
lastReceivedAt: string | null;
|
|
389
|
+
}>;
|
|
390
|
+
recentEvents?: Array<{
|
|
391
|
+
id: string;
|
|
392
|
+
createdAt: string;
|
|
393
|
+
direction: 'in' | 'out' | 'local';
|
|
394
|
+
kind: string;
|
|
395
|
+
stage: string;
|
|
396
|
+
peer: string | null;
|
|
397
|
+
requestId: string | null;
|
|
398
|
+
candidateName: string | null;
|
|
399
|
+
detail: string | null;
|
|
400
|
+
}>;
|
|
386
401
|
} | null;
|
|
387
402
|
lastUpdate?: {
|
|
388
403
|
state: string;
|
|
@@ -172,6 +172,8 @@ Starting in 0.5.2, `/auth sync status` separates sync-system `Last error` from p
|
|
|
172
172
|
|
|
173
173
|
Manual `/auth` switches and `/auth reload` recover from same-node local mirrors only and do not send cross-node pull requests. FoxClaw queries peers only during automatic recovery after it detects a real auth problem. Recovery timeout notifications include the request id, candidate name, peer list, and wait duration; if another auth sync message arrived from the same peer during that wait, the notification says the peer was reachable but this request timed out.
|
|
174
174
|
|
|
175
|
+
Starting in 0.5.8, `/auth sync status` also shows peer activity and the latest sync events. Use `/auth sync events [filter]` to search recent event records by candidate, peer, request id, kind, stage, or detail. Use `/auth sync trace <requestId>` when a notification includes a request id and you want the recent send/receive/result records for that request.
|
|
176
|
+
|
|
175
177
|
If an upgrade or restart interrupts remote candidate usage validation, older versions could leave `auth.json -> .auth-sync-validate-*` behind. Starting in 0.5.2, FoxClaw checks for this at startup, restores `auth.json` to the mirror-status candidate or the newest parseable real `auth.json_*` candidate in the same directory, and removes stale validation temp files.
|
|
176
178
|
|
|
177
179
|
5. Only test refresh-token rotation after you understand the risk:
|
|
@@ -197,6 +199,7 @@ With cross-node sync enabled, this command first requests a cross-node refresh l
|
|
|
197
199
|
- The local node may not be globally idle. Active turns, approvals, inputs, login flows, and mirror writes make imports wait.
|
|
198
200
|
- Usage validation failure rejects the write.
|
|
199
201
|
- Same-name candidates from different account ids are refused.
|
|
202
|
+
- Run `/auth sync events <candidate>` or `/auth sync trace <requestId>` to see the receive, validation, skip, or failure records kept by FoxClaw.
|
|
200
203
|
|
|
201
204
|
**Should I periodically run `/auth refresh all confirm` as keepalive?**
|
|
202
205
|
|
package/docs/user-manual.md
CHANGED
|
@@ -423,7 +423,7 @@ OpenAI does not publish a fixed ChatGPT refresh-token lifetime or an old-token r
|
|
|
423
423
|
|
|
424
424
|
### 6.4 Cross-Node Auth Sync
|
|
425
425
|
|
|
426
|
-
Cross-node auth sync is disabled by default. It is for multiple machines you control that share the same legally owned ChatGPT auth candidate pool, so a token refreshed by Codex on one node can be copied to the others. v1 uses Telegram Bot-to-Bot private messages to carry encrypted files, so it does not require public IPs or FRP. The recommended default is one contact bot per node; other bots on the same node keep using local auth mirroring. In multi-bot mode, the default contact is the first token in `TG_BOT_TOKENS`. The contact bot private chat reports send, receive, queue, import, failure, recovery-query, and manual-intervention states; per-candidate validation failures are shown as candidate failures instead of overwriting the sync-system last error.
|
|
426
|
+
Cross-node auth sync is disabled by default. It is for multiple machines you control that share the same legally owned ChatGPT auth candidate pool, so a token refreshed by Codex on one node can be copied to the others. v1 uses Telegram Bot-to-Bot private messages to carry encrypted files, so it does not require public IPs or FRP. The recommended default is one contact bot per node; other bots on the same node keep using local auth mirroring. In multi-bot mode, the default contact is the first token in `TG_BOT_TOKENS`. The contact bot private chat reports send, receive, queue, import, failure, recovery-query, and manual-intervention states; per-candidate validation failures are shown as candidate failures instead of overwriting the sync-system last error. Recent bot-to-bot traffic is also kept in an event ring so `/auth sync events [filter]` and `/auth sync trace <requestId>` can explain a specific candidate, peer, or request.
|
|
427
427
|
|
|
428
428
|
For the full design, safety boundaries, `.env` examples, and troubleshooting, read the [Cross-Node Auth Sync Setup Guide](./cross-node-auth-sync.md).
|
|
429
429
|
|
|
@@ -464,7 +464,9 @@ Dual-active behavior:
|
|
|
464
464
|
|
|
465
465
|
Commands:
|
|
466
466
|
|
|
467
|
-
- `/auth sync status`: show node id, peers, recent
|
|
467
|
+
- `/auth sync status`: show node id, peers, peer activity, recent sync events, pending imports, the sync-system latest error, and per-candidate failures.
|
|
468
|
+
- `/auth sync events [filter]`: show recent sync event records, optionally filtered by candidate, peer, request id, kind, stage, or detail.
|
|
469
|
+
- `/auth sync trace <requestId>`: show recent records for one request id or event id.
|
|
468
470
|
- `/auth sync test`: send an encrypted ping and wait for peer pong replies to verify peer config, shared key, and Bot-to-Bot private messages.
|
|
469
471
|
- `/auth sync push all`: manually broadcast all locally verified candidates without refreshing tokens. “Sent” does not mean the peer imported files; check `/auth sync status` and `/auth` on the peer.
|
|
470
472
|
|
|
@@ -479,7 +481,7 @@ Equivalent commands:
|
|
|
479
481
|
- `/auth reload` or `/auth_reload`: restart app-server and reload the current `auth.json`.
|
|
480
482
|
- `/auth refresh all`: show the refresh-token rotation risk confirmation.
|
|
481
483
|
- `/auth refresh all confirm`: run Refresh all after accepting the token-rotation risk.
|
|
482
|
-
- `/auth sync status|test|push all`: inspect, test, or manually push cross-node auth sync.
|
|
484
|
+
- `/auth sync status|events|trace|test|push all`: inspect, trace, test, or manually push cross-node auth sync.
|
|
483
485
|
|
|
484
486
|
If the requesting bot runtime has active turns, pending approvals, pending user inputs, or MCP elicitations, FoxClaw refuses manual auth switching to avoid changing accounts mid-request; another idle bot is unaffected.
|
|
485
487
|
|
|
@@ -172,6 +172,8 @@ auth sync 测试完成:已发送 1,收到回应 1。
|
|
|
172
172
|
|
|
173
173
|
手动 `/auth` 切换和 `/auth reload` 只会尝试同节点本地 mirror 恢复,不会主动向跨节点 peer 发起 pull。只有 FoxClaw 检测到当前 auth 真的出现认证问题并进入自动恢复时,才会向 peer 查询可用副本。恢复超时通知会包含 request id、候选名、peer 列表和等待时长;如果等待期间收到过同 peer 的其他 auth sync 消息,通知会标明 peer 可达但该请求超时。
|
|
174
174
|
|
|
175
|
+
从 0.5.8 起,`/auth sync status` 还会显示 peer 最近活跃时间和最近同步事件。`/auth sync events [过滤]` 可按候选名、peer、request id、事件类型、阶段或详情搜索近期事件。通知里带 request id 时,可用 `/auth sync trace <requestId>` 查看该请求最近的发送、接收和处理结果。
|
|
176
|
+
|
|
175
177
|
如果升级或重启正好打断远端候选 usage 验证,旧版本可能留下 `auth.json -> .auth-sync-validate-*` 临时 symlink。0.5.2 起 FoxClaw 启动时会自动检测并恢复到 mirror 状态记录的候选,或同目录最近修改且可解析的真实 `auth.json_*` 候选,然后清理临时文件。
|
|
176
178
|
|
|
177
179
|
5. 只有在完全理解 refresh token 轮换风险时,才测试:
|
|
@@ -197,6 +199,7 @@ auth sync 测试完成:已发送 1,收到回应 1。
|
|
|
197
199
|
- 本机可能不是全局空闲;有 turn、审批、待输入、登录流程或镜像写入时会排队。
|
|
198
200
|
- usage 验证失败会拒绝写盘。
|
|
199
201
|
- 同名候选属于不同 account id 时会拒绝覆盖。
|
|
202
|
+
- 执行 `/auth sync events <候选名>` 或 `/auth sync trace <requestId>`,查看 FoxClaw 记录的接收、验证、跳过或失败流水。
|
|
200
203
|
|
|
201
204
|
**要不要定期 `/auth refresh all confirm` 保活**
|
|
202
205
|
|
package/docs/zh/user-manual.md
CHANGED
|
@@ -423,7 +423,7 @@ OpenAI 没有公开 ChatGPT refresh token 的固定有效期或旧 token 重放
|
|
|
423
423
|
|
|
424
424
|
### 6.4 跨节点 auth 同步
|
|
425
425
|
|
|
426
|
-
跨节点 auth 同步默认关闭。它适合你在多台自己控制的机器上使用同一组合法 ChatGPT 账号候选,并希望某台机器上 Codex 自动刷新出的新 token 能同步到其他机器。v1 使用 Telegram Bot-to-Bot 私聊传输加密文件,不需要公网 IP 或 FRP。推荐每台机器选择一个联系人 bot;同一节点内其他 bot 继续使用本机 auth 镜像。多 bot 模式下,默认联系人是 `TG_BOT_TOKENS` 的第一个 token。联系人 bot
|
|
426
|
+
跨节点 auth 同步默认关闭。它适合你在多台自己控制的机器上使用同一组合法 ChatGPT 账号候选,并希望某台机器上 Codex 自动刷新出的新 token 能同步到其他机器。v1 使用 Telegram Bot-to-Bot 私聊传输加密文件,不需要公网 IP 或 FRP。推荐每台机器选择一个联系人 bot;同一节点内其他 bot 继续使用本机 auth 镜像。多 bot 模式下,默认联系人是 `TG_BOT_TOKENS` 的第一个 token。联系人 bot 的私聊会报告发送、接收、排队、导入、失败、恢复查询和人工介入提示;单个候选验证失败会作为“候选失败”显示,不会覆盖同步系统级最近错误。最近 bot-to-bot 通讯会保存在事件环里,可用 `/auth sync events [过滤]` 和 `/auth sync trace <requestId>` 追查某个候选、peer 或请求。
|
|
427
427
|
|
|
428
428
|
完整设计、安全边界、`.env` 示例和排查步骤见 [跨节点 auth 同步配置指南](./cross-node-auth-sync.md)。
|
|
429
429
|
|
|
@@ -464,7 +464,9 @@ AUTH_SYNC_NODE_ID=workstation-a
|
|
|
464
464
|
|
|
465
465
|
命令:
|
|
466
466
|
|
|
467
|
-
- `/auth sync status`:查看 node id、peer
|
|
467
|
+
- `/auth sync status`:查看 node id、peer、peer 活跃时间、最近同步事件、待导入、系统级最近错误和单候选失败。
|
|
468
|
+
- `/auth sync events [过滤]`:查看最近同步事件,可按候选名、peer、request id、事件类型、阶段或详情过滤。
|
|
469
|
+
- `/auth sync trace <requestId>`:查看某个 request id 或事件 id 的最近流水。
|
|
468
470
|
- `/auth sync test`:发送加密 ping 并等待 peer 返回 pong,确认 peer、共享密钥和 Bot-to-Bot 私聊可用。
|
|
469
471
|
- `/auth sync push all`:手动广播当前节点已验证的全部候选,不刷新 token;“已发送”不等于对端已经导入,需要在 peer 上看 `/auth sync status` 和 `/auth`。
|
|
470
472
|
|
|
@@ -479,7 +481,7 @@ AUTH_SYNC_NODE_ID=workstation-a
|
|
|
479
481
|
- `/auth reload` 或 `/auth_reload`:重启 app-server,重新加载当前 `auth.json`。
|
|
480
482
|
- `/auth refresh all`:显示 refresh token 轮换风险确认。
|
|
481
483
|
- `/auth refresh all confirm`:接受 token 轮换风险后执行刷新全部。
|
|
482
|
-
- `/auth sync status|test|push all
|
|
484
|
+
- `/auth sync status|events|trace|test|push all`:查看、追踪、测试或手动推送跨节点 auth 同步。
|
|
483
485
|
|
|
484
486
|
切换 auth 时,如果当前 bot runtime 还有活跃 turn、待审批、待用户输入或 MCP elicitation,FoxClaw 会先拒绝切换,避免中途换号破坏正在进行的请求;另一个空闲 bot 不受影响。
|
|
485
487
|
|