@foxden-app/foxclaw 0.4.16 → 0.5.0

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/dist/main.js CHANGED
@@ -305,7 +305,7 @@ async function runServeCli() {
305
305
  ?? Promise.resolve(),
306
306
  getAuthSyncStatus: () => authSync?.getStatus() ?? null,
307
307
  authSyncPushAll: () => authSync?.pushAll() ?? Promise.resolve({ sent: 0, skipped: 0 }),
308
- authSyncTest: () => authSync?.testPeers() ?? Promise.resolve({ sent: 0 }),
308
+ authSyncTest: () => authSync?.testPeers() ?? Promise.resolve({ sent: 0, replied: 0, missing: [] }),
309
309
  statusUpdated: () => writeAggregateStatus(),
310
310
  getServiceStatus: async () => ({
311
311
  bots: await Promise.all(runtimes.map(async (runtime) => {
@@ -352,9 +352,13 @@ async function runServeCli() {
352
352
  }
353
353
  activeTelegramAdapters = runtimes.map((runtime) => runtime.telegram);
354
354
  if (config.authSyncEnabled) {
355
- authSync = new CrossNodeAuthSync(buildAuthSyncConfig(config), logger, {
355
+ const authSyncTransportBot = seeds[0];
356
+ const authSyncTransportLabel = authSyncTransportBot.bot.username
357
+ ? `@${authSyncTransportBot.bot.username}`
358
+ : authSyncTransportBot.id;
359
+ authSync = new CrossNodeAuthSync(buildAuthSyncConfig(config, authSyncTransportLabel), logger, {
356
360
  send: async (peer, envelope) => {
357
- await seeds[0].bot.sendDocument(peer, `foxclaw-auth-sync-${Date.now()}.json`, Buffer.from(envelope, 'utf8'), 'FOXCLAW_AUTH_SYNC_V1');
361
+ await authSyncTransportBot.bot.sendDocument(peer, `foxclaw-auth-sync-${Date.now()}.json`, Buffer.from(envelope, 'utf8'), 'FOXCLAW_AUTH_SYNC_V1');
358
362
  },
359
363
  }, {
360
364
  readLocalCandidate: (candidateName) => mirror.readNewestCandidate(candidateName),
@@ -371,10 +375,11 @@ async function runServeCli() {
371
375
  },
372
376
  importCandidate: (candidateName, raw, source) => mirror.importExternalCandidate(candidateName, raw, source),
373
377
  isIdle: authSyncLocalIdle,
378
+ notify: createAuthSyncNotifier(store, authSyncTransportBot.bot),
374
379
  });
375
380
  await authSync.initialize();
376
381
  activeAuthSync = authSync;
377
- attachTelegramAuthSync(seeds[0].bot, authSync, config, logger);
382
+ attachTelegramAuthSync(authSyncTransportBot.bot, authSync, config, logger);
378
383
  authSync.start();
379
384
  }
380
385
  mirror.start();
@@ -455,7 +460,7 @@ async function runServeCli() {
455
460
  ?? Promise.resolve(),
456
461
  getAuthSyncStatus: () => singleAuthSync?.getStatus() ?? null,
457
462
  authSyncPushAll: () => singleAuthSync?.pushAll() ?? Promise.resolve({ sent: 0, skipped: 0 }),
458
- authSyncTest: () => singleAuthSync?.testPeers() ?? Promise.resolve({ sent: 0 }),
463
+ authSyncTest: () => singleAuthSync?.testPeers() ?? Promise.resolve({ sent: 0, replied: 0, missing: [] }),
459
464
  statusUpdated: (status) => {
460
465
  writeRuntimeStatus(config.statusPath, {
461
466
  ...status,
@@ -484,7 +489,8 @@ async function runServeCli() {
484
489
  }
485
490
  core = new BridgeSessionCore(config, store, logger, bot, app, outbound, selfUpdater, singleCoordinator);
486
491
  if (config.authSyncEnabled && singleMirror) {
487
- singleAuthSync = new CrossNodeAuthSync(buildAuthSyncConfig(config), logger, {
492
+ await bot.initializeIdentity();
493
+ singleAuthSync = new CrossNodeAuthSync(buildAuthSyncConfig(config, bot.username ? `@${bot.username}` : 'default'), logger, {
488
494
  send: async (peer, envelope) => {
489
495
  await bot.sendDocument(peer, `foxclaw-auth-sync-${Date.now()}.json`, Buffer.from(envelope, 'utf8'), 'FOXCLAW_AUTH_SYNC_V1');
490
496
  },
@@ -499,6 +505,7 @@ async function runServeCli() {
499
505
  },
500
506
  importCandidate: (candidateName, raw, source) => singleMirror.importExternalCandidate(candidateName, raw, source),
501
507
  isIdle: singleAuthSyncLocalIdle,
508
+ notify: createAuthSyncNotifier(store, bot),
502
509
  });
503
510
  await singleAuthSync.initialize();
504
511
  activeAuthSync = singleAuthSync;
@@ -565,11 +572,139 @@ async function runServeCli() {
565
572
  throw error;
566
573
  }
567
574
  }
575
+ function createAuthSyncNotifier(store, bot) {
576
+ return async (event) => {
577
+ if (!bot.identity)
578
+ return;
579
+ const privateScope = store.getTelegramPrivateScope(bot.identity);
580
+ if (!privateScope)
581
+ return;
582
+ const locale = store.getChatSettings(privateScope.scopeId)?.locale ?? 'en';
583
+ await bot.sendMessage(privateScope.chatId, formatAuthSyncNotification(locale, event));
584
+ };
585
+ }
586
+ function formatAuthSyncNotification(locale, event) {
587
+ const peers = 'peers' in event ? formatPeerList(event.peers, locale) : '';
588
+ if (locale === 'zh') {
589
+ switch (event.kind) {
590
+ case 'candidate_publish_started':
591
+ return `本机 auth 已更新:${event.candidateName}\n处理:正在同步到跨节点 peer:${peers}`;
592
+ case 'candidate_publish_completed':
593
+ return `跨节点 auth 已发出:${event.candidateName}\nPeer:${peers}\n注意:这只代表发送成功,对端导入结果会在对端通知或 /auth sync status 中显示。`;
594
+ case 'candidate_publish_failed':
595
+ return `跨节点 auth 发送失败:${event.candidateName}\nPeer:${peers}\n原因:${event.reason}`;
596
+ case 'push_all_started':
597
+ return `开始手动推送全部 auth:候选 ${event.candidateCount} 个\nPeer:${peers}`;
598
+ case 'push_all_completed':
599
+ return `手动 auth 同步推送完成:已发送 ${event.sent},已跳过 ${event.skipped}\nPeer:${peers}`;
600
+ case 'push_all_failed':
601
+ return `手动 auth 同步推送中断:已发送 ${event.sent},已跳过 ${event.skipped}\nPeer:${peers}\n原因:${event.reason}`;
602
+ case 'remote_bundle_received':
603
+ return [
604
+ `收到跨节点 auth:${event.candidateName}`,
605
+ `来源:${formatSource(event.sourceLabel, event.sourceNodeId)},peer ${event.peer}`,
606
+ `处理:${event.queued ? `本机忙,已排队等待空闲后验证导入;当前待导入 ${event.queueLength}` : '本机空闲,正在验证 usage 后导入'}`,
607
+ ].join('\n');
608
+ case 'remote_import_imported':
609
+ return `${event.mode === 'pull' ? '已拉取并导入跨节点 auth' : '已导入跨节点 auth'}:${event.candidateName}\n来源:${formatSource(event.sourceLabel, event.sourceNodeId)}\n处理:已写入本机 auth 镜像,并同步到同节点 bot home。`;
610
+ case 'remote_import_skipped':
611
+ return `${event.mode === 'pull' ? '跨节点拉取未改动本机文件' : '收到跨节点 auth 但未写盘'}:${event.candidateName}\n来源:${formatSource(event.sourceLabel, event.sourceNodeId)}\n原因:${event.reason}`;
612
+ case 'remote_import_failed':
613
+ return `${event.mode === 'pull' ? '跨节点拉取导入失败' : '跨节点 auth 导入失败'}:${event.candidateName}\n来源:${formatSource(event.sourceLabel, event.sourceNodeId)}\n原因:${event.reason}\n需要注意:如果其他候选也无法恢复,请人工介入重新登录或刷新这个 auth。`;
614
+ case 'recovery_started':
615
+ return `auth 恢复开始:${event.candidateName}\n处理:同节点没有可用较新副本,正在向跨节点 peer 查询:${peers}`;
616
+ case 'recovery_peer_empty':
617
+ return `auth 恢复收到 peer 回应但没有可用副本:${event.candidateName}\nPeer:${event.peer}\n原因:${event.reason}`;
618
+ case 'recovery_peer_bundle_received':
619
+ return `auth 恢复收到 peer 候选:${event.candidateName}\nPeer:${event.peer},来源节点:${event.sourceNodeId}\n处理:正在验证 usage 后导入。`;
620
+ case 'recovery_failed':
621
+ return `auth 恢复已穷尽:${event.candidateName}\n已查询 peer:${peers}\n原因:${event.reason}\n请人工介入:使用 /auth add <name> 或设备登录重新生成可用 auth。`;
622
+ case 'pull_request_received':
623
+ return `收到 peer 的 auth 查询:${event.candidateName}\nPeer:${event.peer},请求节点:${event.requesterNodeId}`;
624
+ case 'pull_response_sent':
625
+ return `已回应 peer 的 auth 查询:${event.candidateName}\nPeer:${event.peer}\n结果:${formatPullResponseResult(event.result, event.reason, locale)}`;
626
+ case 'sync_error':
627
+ return `auth sync 需要注意:\n${event.reason}`;
628
+ }
629
+ }
630
+ switch (event.kind) {
631
+ case 'candidate_publish_started':
632
+ return `Local auth updated: ${event.candidateName}\nAction: syncing to cross-node peers: ${peers}`;
633
+ case 'candidate_publish_completed':
634
+ return `Cross-node auth sent: ${event.candidateName}\nPeers: ${peers}\nNote: this confirms send success only; peer import is reported on the receiving node.`;
635
+ case 'candidate_publish_failed':
636
+ return `Cross-node auth send failed: ${event.candidateName}\nPeers: ${peers}\nReason: ${event.reason}`;
637
+ case 'push_all_started':
638
+ return `Manual auth sync push started: ${event.candidateCount} candidates\nPeers: ${peers}`;
639
+ case 'push_all_completed':
640
+ return `Manual auth sync push complete: sent ${event.sent}, skipped ${event.skipped}\nPeers: ${peers}`;
641
+ case 'push_all_failed':
642
+ return `Manual auth sync push stopped: sent ${event.sent}, skipped ${event.skipped}\nPeers: ${peers}\nReason: ${event.reason}`;
643
+ case 'remote_bundle_received':
644
+ return [
645
+ `Received cross-node auth: ${event.candidateName}`,
646
+ `Source: ${formatSource(event.sourceLabel, event.sourceNodeId)}, peer ${event.peer}`,
647
+ `Action: ${event.queued ? `queued until this node is idle; pending imports ${event.queueLength}` : 'validating usage before import'}`,
648
+ ].join('\n');
649
+ case 'remote_import_imported':
650
+ return `${event.mode === 'pull' ? 'Pulled and imported cross-node auth' : 'Imported cross-node auth'}: ${event.candidateName}\nSource: ${formatSource(event.sourceLabel, event.sourceNodeId)}\nAction: written to the local auth mirror and same-node bot homes.`;
651
+ case 'remote_import_skipped':
652
+ return `${event.mode === 'pull' ? 'Cross-node pull did not change local files' : 'Received cross-node auth but did not write it'}: ${event.candidateName}\nSource: ${formatSource(event.sourceLabel, event.sourceNodeId)}\nReason: ${event.reason}`;
653
+ case 'remote_import_failed':
654
+ return `${event.mode === 'pull' ? 'Cross-node pull import failed' : 'Cross-node auth import failed'}: ${event.candidateName}\nSource: ${formatSource(event.sourceLabel, event.sourceNodeId)}\nReason: ${event.reason}\nAttention: if no other candidate can recover this account, run device login or refresh this auth manually.`;
655
+ case 'recovery_started':
656
+ return `Auth recovery started: ${event.candidateName}\nAction: no newer same-node copy was available; querying cross-node peers: ${peers}`;
657
+ case 'recovery_peer_empty':
658
+ return `Auth recovery peer replied without usable auth: ${event.candidateName}\nPeer: ${event.peer}\nReason: ${event.reason}`;
659
+ case 'recovery_peer_bundle_received':
660
+ return `Auth recovery peer returned a candidate: ${event.candidateName}\nPeer: ${event.peer}, source node: ${event.sourceNodeId}\nAction: validating usage before import.`;
661
+ case 'recovery_failed':
662
+ return `Auth recovery exhausted: ${event.candidateName}\nPeers checked: ${peers}\nReason: ${event.reason}\nManual action: use /auth add <name> or device login to create a usable auth.`;
663
+ case 'pull_request_received':
664
+ return `Received peer auth recovery request: ${event.candidateName}\nPeer: ${event.peer}, requester node: ${event.requesterNodeId}`;
665
+ case 'pull_response_sent':
666
+ return `Replied to peer auth recovery request: ${event.candidateName}\nPeer: ${event.peer}\nResult: ${formatPullResponseResult(event.result, event.reason, locale)}`;
667
+ case 'sync_error':
668
+ return `Auth sync needs attention:\n${event.reason}`;
669
+ }
670
+ }
671
+ function formatPeerList(peers, locale) {
672
+ return peers.length > 0 ? peers.join(', ') : (locale === 'zh' ? '无' : 'none');
673
+ }
674
+ function formatSource(sourceLabel, sourceNodeId) {
675
+ return sourceLabel === sourceNodeId ? sourceNodeId : `${sourceLabel} / ${sourceNodeId}`;
676
+ }
677
+ function formatPullResponseResult(result, reason, locale) {
678
+ const suffix = reason ? ` (${reason})` : '';
679
+ if (locale === 'zh') {
680
+ switch (result) {
681
+ case 'sent':
682
+ return `已返回较新候选${suffix}`;
683
+ case 'candidate_not_found':
684
+ return `本机没有这个候选${suffix}`;
685
+ case 'account_mismatch':
686
+ return `账号不匹配,未返回${suffix}`;
687
+ case 'not_newer':
688
+ return `本机副本不更新,未返回${suffix}`;
689
+ }
690
+ }
691
+ switch (result) {
692
+ case 'sent':
693
+ return `sent newer candidate${suffix}`;
694
+ case 'candidate_not_found':
695
+ return `candidate not found${suffix}`;
696
+ case 'account_mismatch':
697
+ return `account mismatch; nothing sent${suffix}`;
698
+ case 'not_newer':
699
+ return `local copy is not newer; nothing sent${suffix}`;
700
+ }
701
+ }
568
702
  const AUTH_SYNC_TELEGRAM_CAPTION = 'FOXCLAW_AUTH_SYNC_V1';
569
- function buildAuthSyncConfig(config) {
703
+ function buildAuthSyncConfig(config, transportLabel = null) {
570
704
  return {
571
705
  enabled: config.authSyncEnabled,
572
706
  transport: config.authSyncTransport,
707
+ transportLabel,
573
708
  key: config.authSyncKey,
574
709
  peers: config.authSyncPeers,
575
710
  nodeId: config.authSyncNodeId,
@@ -43,6 +43,10 @@ export declare class BridgeStore {
43
43
  setTelegramOffset(botKey: string, updateId: number): void;
44
44
  rememberTelegramPrivateScope(botId: string, scopeId: string, chatId: string): void;
45
45
  getTelegramPrivateChatId(botId: string): string | null;
46
+ getTelegramPrivateScope(botId: string): {
47
+ scopeId: string;
48
+ chatId: string;
49
+ } | null;
46
50
  getBinding(chatId: string): ThreadBinding | null;
47
51
  setBinding(chatId: string, threadId: string, cwd: string | null): void;
48
52
  clearBinding(chatId: string): void;
@@ -178,6 +178,10 @@ export class BridgeStore {
178
178
  const row = this.db.prepare('SELECT chat_id FROM telegram_private_scopes WHERE bot_id = ?').get(botId);
179
179
  return row ? String(row.chat_id) : null;
180
180
  }
181
+ getTelegramPrivateScope(botId) {
182
+ const row = this.db.prepare('SELECT scope_id, chat_id FROM telegram_private_scopes WHERE bot_id = ?').get(botId);
183
+ return row ? { scopeId: String(row.scope_id), chatId: String(row.chat_id) } : null;
184
+ }
181
185
  getBinding(chatId) {
182
186
  const row = this.db.prepare('SELECT chat_id, thread_id, cwd, updated_at FROM chat_bindings WHERE chat_id = ?').get(chatId);
183
187
  if (!row)
package/dist/types.d.ts CHANGED
@@ -363,6 +363,7 @@ export interface RuntimeStatus {
363
363
  authSync?: {
364
364
  enabled: boolean;
365
365
  nodeId: string | null;
366
+ transportLabel: string | null;
366
367
  peers: string[];
367
368
  pendingImports: number;
368
369
  lastSentAt: string | null;
@@ -11,6 +11,7 @@ Use it when:
11
11
  - You legally own and maintain the ChatGPT accounts and auth files.
12
12
  - Multiple machines run FoxClaw, and each machine has at least one Telegram bot.
13
13
  - You want auth files to stay fresh across nodes without routinely rotating refresh tokens.
14
+ - The recommended default is one contact bot per node for cross-node sync. Other bots on the same node continue to use local auth mirroring.
14
15
 
15
16
  Do not use it when:
16
17
 
@@ -44,6 +45,8 @@ Assume two machines:
44
45
  - Node A: bot `@foxclaw_node_a_bot`
45
46
  - Node B: bot `@foxclaw_node_b_bot`
46
47
 
48
+ These are the two node contact bots. `AUTH_SYNC_PEERS` only needs peer node contact bots; you do not need to list every bot running on the same machine. In multi-bot mode, FoxClaw uses the first token in `TG_BOT_TOKENS` as the local contact bot by default. If you want bot 5 to be the contact, put bot 5's token first, or enable Bot-to-Bot for every local bot as a temporary fallback.
49
+
47
50
  Each node should already work independently:
48
51
 
49
52
  ```bash
@@ -62,16 +65,20 @@ In a private Telegram chat with each bot, verify:
62
65
 
63
66
  Repeat this for every participating bot:
64
67
 
65
- 1. Open Telegram and enter `@BotFather`.
66
- 2. Send `/mybots`.
67
- 3. Select the bot that will participate in auth sync.
68
- 4. Open the bot settings / Mini App settings interface.
68
+ 1. Prefer the latest Telegram mobile client; some desktop or older clients do not show the setting.
69
+ 2. Open `https://t.me/BotFather?startapp`, or open the `@BotFather` profile and tap **Open App**.
70
+ 3. In the BotFather MiniApp, select the contact bot that will participate in auth sync.
71
+ 4. Open Settings / Bot Settings.
69
72
  5. Find **Bot-to-Bot Communication Mode**.
70
73
  6. Enable it.
71
- 7. Repeat for every peer bot.
74
+ 7. Repeat for every node contact bot.
75
+
76
+ Do not use `/mybots` → Bot Settings → **Configure Mini App**. That configures your bot's Mini App URL, not Bot-to-Bot Communication Mode.
72
77
 
73
78
  Private cross-node sync requires this mode on both bots. Enabling it on only one side is usually not enough for two bots to exchange private sync packets.
74
79
 
80
+ If you see `Bad Request: USER_BOT_TO_BOT_DISABLED`, first confirm that both the sender contact bot and recipient contact bot have Bot-to-Bot enabled. In multi-bot mode, the sender contact is the first token in `TG_BOT_TOKENS` by default; it may not be the bot where you typed the command.
81
+
75
82
  ## .env Configuration
76
83
 
77
84
  Use the same `AUTH_SYNC_KEY` and `AUTH_SYNC_CLUSTER_ID` on all nodes, but give each node a different `AUTH_SYNC_NODE_ID`.
@@ -79,6 +86,7 @@ Use the same `AUTH_SYNC_KEY` and `AUTH_SYNC_CLUSTER_ID` on all nodes, but give e
79
86
  Node A:
80
87
 
81
88
  ```dotenv
89
+ TG_BOT_TOKENS=<node-a-contact-token>,<node-a-other-bot-token>
82
90
  AUTH_SYNC_ENABLED=true
83
91
  AUTH_SYNC_KEY=<shared key with at least 32 bytes>
84
92
  AUTH_SYNC_CLUSTER_ID=my-codex-auth-pool
@@ -89,6 +97,7 @@ AUTH_SYNC_PEERS=@foxclaw_node_b_bot
89
97
  Node B:
90
98
 
91
99
  ```dotenv
100
+ TG_BOT_TOKENS=<node-b-contact-token>,<node-b-other-bot-token>
92
101
  AUTH_SYNC_ENABLED=true
93
102
  AUTH_SYNC_KEY=<shared key with at least 32 bytes>
94
103
  AUTH_SYNC_CLUSTER_ID=my-codex-auth-pool
@@ -132,6 +141,14 @@ You should see the node id, peer list, and pending imports.
132
141
 
133
142
  Node A should report that it sent a test ping. Node B's `/auth sync status` should show a recent receive or test-state change.
134
143
 
144
+ Starting in 0.4.17, `/auth sync test` waits for an encrypted pong from peers. A healthy result looks like:
145
+
146
+ ```text
147
+ Auth sync test complete: sent 1, replies 1.
148
+ ```
149
+
150
+ If it shows `Missing replies: @peer_bot`, Telegram delivery may have succeeded, but the peer did not receive, decrypt, pass allowlist validation, or run the same auth sync configuration.
151
+
135
152
  3. Use a low-risk candidate for the first broadcast. Make sure every runtime is idle, then run on node A:
136
153
 
137
154
  ```text
@@ -147,6 +164,10 @@ Node A should report that it sent a test ping. Node B's `/auth sync status` shou
147
164
 
148
165
  Confirm that pending imports were processed, or that the candidate exists or has a newer timestamp.
149
166
 
167
+ Note: `/auth sync push all` saying “sent” only means this node successfully handed encrypted packages to Telegram. It does not prove the peer wrote files. The peer imports only when it is globally idle, usage validation succeeds, same-name candidates belong to the same account id, and the remote `last_refresh` is newer than the local copy. If the local file is already equal or newer, it will not change and `Last import` may remain empty.
168
+
169
+ When cross-node sync is enabled, the contact bot private chat receives node-level notifications: local auth updates and the peers being contacted, received remote bundles and whether they were queued or immediately validated, import success/skip/failure reasons, recovery peer queries and peer replies, and a manual-intervention notice when every peer lacks an importable copy. Notifications never include auth contents, tokens, or encrypted bundle payloads.
170
+
150
171
  5. Only test refresh-token rotation after you understand the risk:
151
172
 
152
173
  ```text
@@ -174,4 +195,3 @@ With cross-node sync enabled, this command first requests a cross-node refresh l
174
195
  **Should I periodically run `/auth refresh all confirm` as keepalive?**
175
196
 
176
197
  No. Codex refreshes automatically when access tokens expire. Cross-node auth sync propagates auth files that have already refreshed successfully; Refresh all should remain a maintenance command, not a routine keepalive.
177
-
@@ -419,18 +419,19 @@ OpenAI does not publish a fixed ChatGPT refresh-token lifetime or an old-token r
419
419
 
420
420
  ### 6.4 Cross-Node Auth Sync
421
421
 
422
- 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. Enable Bot-to-Bot Communication Mode for the participating bots in BotFather first.
422
+ 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.
423
423
 
424
424
  For the full design, safety boundaries, `.env` examples, and troubleshooting, read the [Cross-Node Auth Sync Setup Guide](./cross-node-auth-sync.md).
425
425
 
426
426
  In `@BotFather`, repeat this for every participating bot:
427
427
 
428
- 1. Open `@BotFather`.
429
- 2. Send `/mybots`.
430
- 3. Select the bot that will participate in sync.
431
- 4. Open the bot settings / Mini App settings interface.
432
- 5. Find and enable **Bot-to-Bot Communication Mode**.
433
- 6. Repeat for every peer bot; private sync requires this mode on both sender and recipient.
428
+ 1. Use the latest Telegram mobile client to open `https://t.me/BotFather?startapp`, or open the `@BotFather` profile and tap **Open App**.
429
+ 2. In the BotFather MiniApp, select the contact bot that will participate in sync.
430
+ 3. Open Settings / Bot Settings.
431
+ 4. Find and enable **Bot-to-Bot Communication Mode**.
432
+ 5. Repeat for every node contact bot; private sync requires this mode on both sender and recipient.
433
+
434
+ Do not use `/mybots` → Bot Settings → **Configure Mini App**. That configures your bot's Mini App URL, not Bot-to-Bot Communication Mode.
434
435
 
435
436
  Example:
436
437
 
@@ -460,8 +461,8 @@ Dual-active behavior:
460
461
  Commands:
461
462
 
462
463
  - `/auth sync status`: show node id, peers, recent sends/receives/imports, pending imports, and the latest error.
463
- - `/auth sync test`: send an encrypted ping to verify peer config, shared key, and Bot-to-Bot private messages.
464
- - `/auth sync push all`: manually broadcast all locally verified candidates without refreshing tokens.
464
+ - `/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.
465
+ - `/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.
465
466
 
466
467
  Equivalent commands:
467
468
 
@@ -11,6 +11,7 @@
11
11
  - 这些 ChatGPT 账号和 auth 文件都由你合法拥有和维护。
12
12
  - 多台机器都运行 FoxClaw,并且每台机器至少有一个 Telegram bot。
13
13
  - 你希望 auth 文件在节点间自动保持较新,但不希望日常主动旋转 refresh token。
14
+ - 默认推荐每台机器只选择一个“联系人 bot”参与跨节点同步;同一节点内其他 bot 继续使用原本的本机 auth 镜像。
14
15
 
15
16
  不适合:
16
17
 
@@ -44,6 +45,8 @@ Telegram 官方 Bot Features 文档说明:私聊 bot-to-bot 需要发送方和
44
45
  - 节点 A:bot `@foxclaw_node_a_bot`
45
46
  - 节点 B:bot `@foxclaw_node_b_bot`
46
47
 
48
+ 这两个 bot 就是两个节点的联系人。`AUTH_SYNC_PEERS` 只需要写 peer 节点的联系人 bot,不需要把同一台机器上的所有 bot 都互相列进去。多 bot 模式下,FoxClaw 默认使用 `TG_BOT_TOKENS` 里的第一个 token 作为本节点联系人;如果你希望 5 号 bot 当联系人,就把 5 号 token 放到 `TG_BOT_TOKENS` 第一位,或者给全部 bot 都开启 Bot-to-Bot 作为临时兜底。
49
+
47
50
  每个节点都应该先能独立使用 FoxClaw:
48
51
 
49
52
  ```bash
@@ -62,16 +65,20 @@ foxclaw start
62
65
 
63
66
  对参与同步的每一个 bot 都做一遍:
64
67
 
65
- 1. 打开 Telegram,进入 `@BotFather`。
66
- 2. 发送 `/mybots`。
67
- 3. 选择要参与同步的 bot。
68
- 4. 打开 BotFather 的 bot settings / Mini App 设置界面。
68
+ 1. 建议使用最新版 Telegram 手机客户端;部分桌面端或旧客户端看不到这个开关。
69
+ 2. 打开 `https://t.me/BotFather?startapp`,或进入 `@BotFather` 资料页后点击 **Open App / 打开应用**。
70
+ 3. BotFather MiniApp 中选择要参与同步的联系人 bot。
71
+ 4. 进入 Settings / Bot Settings。
69
72
  5. 找到 **Bot-to-Bot Communication Mode**。
70
73
  6. 启用该开关。
71
- 7. 对所有 peer bot 重复以上步骤。
74
+ 7. 对所有节点的联系人 bot 重复以上步骤。
75
+
76
+ 不要走 `/mybots` → Bot Settings → **Configure Mini App**。那是配置你自己 bot 的 Mini App URL,不是 Bot-to-Bot Communication Mode。
72
77
 
73
78
  私聊跨节点同步要求双方都开启这个模式。只开一个通常不足以让两个 bot 互相私聊传输同步包。
74
79
 
80
+ 如果你看到 `Bad Request: USER_BOT_TO_BOT_DISABLED`,优先确认两件事:发送方联系人 bot 和接收方联系人 bot 都已经开启 Bot-to-Bot;多 bot 模式下,发送方联系人默认是 `TG_BOT_TOKENS` 的第一个 token,不一定是你当前输入命令的 bot。
81
+
75
82
  ## .env 配置
76
83
 
77
84
  两台机器使用相同的 `AUTH_SYNC_KEY` 和 `AUTH_SYNC_CLUSTER_ID`,但 `AUTH_SYNC_NODE_ID` 必须不同。
@@ -79,6 +86,7 @@ foxclaw start
79
86
  节点 A:
80
87
 
81
88
  ```dotenv
89
+ TG_BOT_TOKENS=<node-a-contact-token>,<node-a-other-bot-token>
82
90
  AUTH_SYNC_ENABLED=true
83
91
  AUTH_SYNC_KEY=<至少32字节的共享密钥>
84
92
  AUTH_SYNC_CLUSTER_ID=my-codex-auth-pool
@@ -89,6 +97,7 @@ AUTH_SYNC_PEERS=@foxclaw_node_b_bot
89
97
  节点 B:
90
98
 
91
99
  ```dotenv
100
+ TG_BOT_TOKENS=<node-b-contact-token>,<node-b-other-bot-token>
92
101
  AUTH_SYNC_ENABLED=true
93
102
  AUTH_SYNC_KEY=<至少32字节的共享密钥>
94
103
  AUTH_SYNC_CLUSTER_ID=my-codex-auth-pool
@@ -132,6 +141,14 @@ foxclaw restart
132
141
 
133
142
  节点 A 应提示已向 peer 发送测试 ping。节点 B 的 `/auth sync status` 应能看到最近收到的同步事件或测试状态变化。
134
143
 
144
+ 从 0.4.17 起,`/auth sync test` 会等待 peer 返回加密 pong。正常结果应该类似:
145
+
146
+ ```text
147
+ auth sync 测试完成:已发送 1,收到回应 1。
148
+ ```
149
+
150
+ 如果显示 `未回应:@peer_bot`,说明 Telegram 发送可能成功,但对方没有成功接收、解密、通过 allowlist,或没有运行同一组 auth sync 配置。
151
+
135
152
  3. 用低风险候选做第一次广播。先确认所有 bot runtime 空闲,然后在节点 A 执行:
136
153
 
137
154
  ```text
@@ -147,6 +164,10 @@ foxclaw restart
147
164
 
148
165
  确认待导入清单被处理,或候选已经出现/更新时间变新。
149
166
 
167
+ 注意:`/auth sync push all` 的“已发送”只代表本节点把加密包发给 Telegram 成功,不代表对端已经写盘。对端只有在全局空闲、usage 验证通过、同名候选 account id 一致,并且远端 `last_refresh` 比本地更新时才会覆盖文件。如果本地已经是相同或更新版本,文件不会变化,`最近导入` 也可能保持为空。
168
+
169
+ 启用跨节点同步后,联系人 bot 的私聊会收到节点级通知:本机 auth 更新并开始发往哪些 peer、收到远端包后是排队还是立即验证、导入成功/跳过/失败原因、auth 恢复时正在查询哪些 peer、peer 回应了什么,以及所有 peer 都无法提供可用副本时的人工介入提示。通知不会包含 auth 内容、token 或同步密文。
170
+
150
171
  5. 只有在完全理解 refresh token 轮换风险时,才测试:
151
172
 
152
173
  ```text
@@ -174,4 +195,3 @@ foxclaw restart
174
195
  **要不要定期 `/auth refresh all confirm` 保活**
175
196
 
176
197
  不要。Codex 会按 access token 到期自动刷新。FoxClaw 的跨节点同步会同步“已经成功刷新的新 auth”,不应该把 refresh all 当作日常保活。
177
-
@@ -419,18 +419,19 @@ OpenAI 没有公开 ChatGPT refresh token 的固定有效期或旧 token 重放
419
419
 
420
420
  ### 6.4 跨节点 auth 同步
421
421
 
422
- 跨节点 auth 同步默认关闭。它适合你在多台自己控制的机器上使用同一组合法 ChatGPT 账号候选,并希望某台机器上 Codex 自动刷新出的新 token 能同步到其他机器。v1 使用 Telegram Bot-to-Bot 私聊传输加密文件,不需要公网 IP 或 FRP;需要先在 BotFather 为参与同步的 bot 开启 Bot-to-Bot Communication Mode。
422
+ 跨节点 auth 同步默认关闭。它适合你在多台自己控制的机器上使用同一组合法 ChatGPT 账号候选,并希望某台机器上 Codex 自动刷新出的新 token 能同步到其他机器。v1 使用 Telegram Bot-to-Bot 私聊传输加密文件,不需要公网 IP 或 FRP。推荐每台机器选择一个联系人 bot;同一节点内其他 bot 继续使用本机 auth 镜像。多 bot 模式下,默认联系人是 `TG_BOT_TOKENS` 的第一个 token。联系人 bot 的私聊会报告发送、接收、排队、导入、失败、恢复查询和人工介入提示。
423
423
 
424
424
  完整设计、安全边界、`.env` 示例和排查步骤见 [跨节点 auth 同步配置指南](./cross-node-auth-sync.md)。
425
425
 
426
426
  在 `@BotFather` 中对每个参与同步的 bot 执行:
427
427
 
428
- 1. 打开 `@BotFather`。
429
- 2. 发送 `/mybots`。
430
- 3. 选择参与同步的 bot
431
- 4. 打开 bot settings / Mini App 设置界面。
432
- 5. 找到并启用 **Bot-to-Bot Communication Mode**。
433
- 6. 对所有 peer bot 重复;私聊同步要求发送方和接收方都开启。
428
+ 1. 用最新版 Telegram 手机客户端打开 `https://t.me/BotFather?startapp`,或进入 `@BotFather` 资料页点 **Open App / 打开应用**。
429
+ 2. BotFather MiniApp 中选择参与同步的联系人 bot。
430
+ 3. 打开 Settings / Bot Settings
431
+ 4. 找到并启用 **Bot-to-Bot Communication Mode**。
432
+ 5. 对所有节点的联系人 bot 重复;私聊同步要求发送方和接收方都开启。
433
+
434
+ 不要走 `/mybots` → Bot Settings → **Configure Mini App**;那是配置 bot 的 Mini App URL,不是 Bot-to-Bot Communication Mode。
434
435
 
435
436
  配置示例:
436
437
 
@@ -460,8 +461,8 @@ AUTH_SYNC_NODE_ID=workstation-a
460
461
  命令:
461
462
 
462
463
  - `/auth sync status`:查看 node id、peer、最近收发、最近导入、待导入和最近错误。
463
- - `/auth sync test`:发送加密 ping,确认 peer、共享密钥和 Bot-to-Bot 私聊可用。
464
- - `/auth sync push all`:手动广播当前节点已验证的全部候选,不刷新 token
464
+ - `/auth sync test`:发送加密 ping 并等待 peer 返回 pong,确认 peer、共享密钥和 Bot-to-Bot 私聊可用。
465
+ - `/auth sync push all`:手动广播当前节点已验证的全部候选,不刷新 token;“已发送”不等于对端已经导入,需要在 peer 上看 `/auth sync status` 和 `/auth`。
465
466
 
466
467
  命令等价用法:
467
468
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.4.16",
3
+ "version": "0.5.0",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
@@ -70,7 +70,7 @@ Telegram behavior:
70
70
  - `/auth` paginates large candidate inventories at 8 rows per page, supports all/enabled/attention filters and filename search, renders actual observed quota windows in text rows, uses compact two-number remaining quota labels on buttons, and omits the repeated `auth.json_` prefix from panel labels without renaming files
71
71
  - treat the `/auth` `not recently refreshed` state as a maintenance hint only: OpenAI does not publish a fixed ChatGPT refresh-token lifetime or replay grace period, and FoxClaw must not add routine bulk keepalive refreshes
72
72
  - `/auth refresh all` remains a command-only maintenance action, not a panel button; it requires explicit token-rotation risk confirmation, then force-refreshes every ChatGPT candidate through Codex `account/read refreshToken=true`, validates usage, mirrors successful candidates, and restores the original current auth while every runtime is globally idle
73
- - optional cross-node auth sync uses Telegram Bot-to-Bot private messages with encrypted auth bundles; configure `AUTH_SYNC_ENABLED=true`, a shared `AUTH_SYNC_KEY`, and `AUTH_SYNC_PEERS`, then use `/auth sync status`, `/auth sync test`, and `/auth sync push all`; cross-node recovery pulls already-held valid peer copies and does not auto-refresh tokens
73
+ - optional cross-node auth sync uses Telegram Bot-to-Bot private messages with encrypted auth bundles; the default topology is one contact bot per node, and in multi-bot mode the first `TG_BOT_TOKENS` entry is the contact transport; configure `AUTH_SYNC_ENABLED=true`, a shared `AUTH_SYNC_KEY`, and `AUTH_SYNC_PEERS` containing peer contact bot usernames, then use `/auth sync status`, `/auth sync test`, and `/auth sync push all`; `/auth sync test` waits for encrypted peer pong replies, while push-all only proves send success and peer import must be checked on the receiver
74
74
  - when cross-node auth sync is enabled, `/auth refresh all confirm` must obtain a cross-node refresh lease before rotating refresh tokens; any busy, denying, or non-responsive peer blocks the refresh
75
75
  - if `TG_BOT_TOKEN` is also set to one exact token from `TG_BOT_TOKENS`, that bot uses the default/shared-terminal runtime instead of an isolated Telegram home
76
76
  - if Weixin is enabled alongside multiple Telegram bots, it remains on the default Codex runtime instead of borrowing a Telegram bot runtime