@foxden-app/foxclaw 0.4.17 → 0.5.1

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 CHANGED
@@ -2,17 +2,27 @@
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.4.17 - 2026-06-04
5
+ ## 0.5.1 - 2026-06-04
6
+
7
+ ### 中文
8
+ - 串行化跨节点 auth 远端导入验证,避免多个同步包同时触发 usage 验证并反复重启同一个 Codex app-server,导致误报 `Codex app bridge stopped` 或 `SIGTERM`。
9
+
10
+ ### English
11
+ - Serialized cross-node auth remote import validation so multiple incoming bundles no longer validate in parallel and repeatedly restart the same Codex app-server, avoiding false `Codex app bridge stopped` or `SIGTERM` failures.
12
+
13
+ ## 0.5.0 - 2026-06-04
6
14
 
7
15
  ### 中文
8
16
  - 明确跨节点 auth 同步推荐架构:每节点一个联系人 bot;多 bot 模式默认使用 `TG_BOT_TOKENS` 的第一个 token 作为联系人,同节点其他 bot 继续走本机 auth 镜像。
9
17
  - `/auth sync status` 和 `/status` 现在显示联系人 bot,避免在非联系人 bot 上执行命令时误判实际发包身份。
10
18
  - `/auth sync test` 升级为等待 peer 加密 pong 的真实握手,并显示未回应 peer;同时文档补充 BotFather MiniApp 入口、不要误进 Configure Mini App,以及 `push all` 只代表发送成功、不代表对端已导入。
19
+ - 跨节点 auth 同步现在会通过联系人 bot 私聊通知发送、接收、排队、导入、跳过、失败和恢复查询进度;所有 peer 都无法提供可用副本时会明确提示人工介入。
11
20
 
12
21
  ### English
13
22
  - Clarified the recommended cross-node auth sync topology: one contact bot per node. In multi-bot mode, the first `TG_BOT_TOKENS` entry is the default contact while other same-node bots keep using local auth mirroring.
14
23
  - `/auth sync status` and `/status` now show the contact bot so commands run from a non-contact bot no longer obscure the actual sender identity.
15
24
  - `/auth sync test` now waits for encrypted peer pong replies and reports missing peers. Documentation now covers the BotFather MiniApp entry point, avoids the Configure Mini App confusion, and explains that `push all` only proves send success, not peer import.
25
+ - Cross-node auth sync now sends contact-bot private notifications for send, receive, queue, import, skip, failure, and recovery query progress; it explicitly asks for manual intervention when every peer lacks an importable copy.
16
26
 
17
27
  ## 0.4.16 - 2026-06-04
18
28
 
package/README.md CHANGED
@@ -212,7 +212,7 @@ TG_BOT_TOKEN=123456:token_a
212
212
 
213
213
  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 摘要。
214
214
 
215
- 多台机器共享同一合法账号池时,可以启用可选跨节点 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 已持有的有效副本。跨节点恢复不会自动刷新 token,`/auth refresh all confirm` 会先申请跨节点刷新锁。完整配置、`@BotFather` 操作和验证步骤见 [跨节点 auth 同步配置指南](./docs/zh/cross-node-auth-sync.md)。
215
+ 多台机器共享同一合法账号池时,可以启用可选跨节点 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 私聊里报告发送、接收、排队、导入、失败和人工介入提示。跨节点恢复不会自动刷新 token,`/auth refresh all confirm` 会先申请跨节点刷新锁。完整配置、`@BotFather` 操作和验证步骤见 [跨节点 auth 同步配置指南](./docs/zh/cross-node-auth-sync.md)。
216
216
 
217
217
  如果你需要一路 Telegram 与终端互通 session,把同一个 token 同时填入 `TG_BOT_TOKENS` 和 `TG_BOT_TOKEN`。这个 bot 使用默认 `CODEX_HOME`(未设置时通常是 `~/.codex`)和默认 auth,因此能看到终端 Codex 的本地线程;它不再享有隔离 runtime 的“互不影响”保证,切换 auth 会影响终端和其他默认 runtime。
218
218
 
package/README_EN.md CHANGED
@@ -212,7 +212,7 @@ TG_BOT_TOKEN=123456:token_a
212
212
 
213
213
  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.
214
214
 
215
- 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. 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).
215
+ 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).
216
216
 
217
217
  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.
218
218
 
@@ -35,6 +35,101 @@ export interface AuthSyncValidationResult {
35
35
  ok: boolean;
36
36
  reason?: string | null;
37
37
  }
38
+ export type AuthSyncRemoteImportMode = 'push' | 'pull';
39
+ export type AuthSyncPullResponseResult = 'sent' | 'candidate_not_found' | 'account_mismatch' | 'not_newer';
40
+ export type AuthSyncNotification = {
41
+ kind: 'candidate_publish_started';
42
+ candidateName: string;
43
+ peers: string[];
44
+ } | {
45
+ kind: 'candidate_publish_completed';
46
+ candidateName: string;
47
+ peers: string[];
48
+ } | {
49
+ kind: 'candidate_publish_failed';
50
+ candidateName: string;
51
+ peers: string[];
52
+ reason: string;
53
+ } | {
54
+ kind: 'push_all_started';
55
+ candidateCount: number;
56
+ peers: string[];
57
+ } | {
58
+ kind: 'push_all_completed';
59
+ sent: number;
60
+ skipped: number;
61
+ peers: string[];
62
+ } | {
63
+ kind: 'push_all_failed';
64
+ sent: number;
65
+ skipped: number;
66
+ peers: string[];
67
+ reason: string;
68
+ } | {
69
+ kind: 'remote_bundle_received';
70
+ candidateName: string;
71
+ sourceNodeId: string;
72
+ sourceLabel: string;
73
+ peer: string;
74
+ queued: boolean;
75
+ queueLength: number;
76
+ } | {
77
+ kind: 'remote_import_imported';
78
+ candidateName: string;
79
+ sourceNodeId: string;
80
+ sourceLabel: string;
81
+ peer: string;
82
+ mode: AuthSyncRemoteImportMode;
83
+ } | {
84
+ kind: 'remote_import_skipped';
85
+ candidateName: string;
86
+ sourceNodeId: string;
87
+ sourceLabel: string;
88
+ peer: string;
89
+ mode: AuthSyncRemoteImportMode;
90
+ reason: string;
91
+ } | {
92
+ kind: 'remote_import_failed';
93
+ candidateName: string;
94
+ sourceNodeId: string;
95
+ sourceLabel: string;
96
+ peer: string;
97
+ mode: AuthSyncRemoteImportMode;
98
+ reason: string;
99
+ } | {
100
+ kind: 'recovery_started';
101
+ candidateName: string;
102
+ peers: string[];
103
+ } | {
104
+ kind: 'recovery_peer_empty';
105
+ candidateName: string;
106
+ peer: string;
107
+ reason: string;
108
+ } | {
109
+ kind: 'recovery_peer_bundle_received';
110
+ candidateName: string;
111
+ peer: string;
112
+ sourceNodeId: string;
113
+ } | {
114
+ kind: 'recovery_failed';
115
+ candidateName: string;
116
+ peers: string[];
117
+ reason: string;
118
+ } | {
119
+ kind: 'pull_request_received';
120
+ candidateName: string;
121
+ peer: string;
122
+ requesterNodeId: string;
123
+ } | {
124
+ kind: 'pull_response_sent';
125
+ candidateName: string;
126
+ peer: string;
127
+ result: AuthSyncPullResponseResult;
128
+ reason: string | null;
129
+ } | {
130
+ kind: 'sync_error';
131
+ reason: string;
132
+ };
38
133
  export interface AuthSyncImportCallbacks {
39
134
  readLocalCandidate: (candidateName: string) => Promise<AuthMirrorCandidateRecord | null>;
40
135
  listLocalCandidates: () => Promise<AuthMirrorCandidateRecord[]>;
@@ -44,6 +139,7 @@ export interface AuthSyncImportCallbacks {
44
139
  label?: string | null;
45
140
  }) => Promise<AuthMirrorImportResult>;
46
141
  isIdle: () => boolean;
142
+ notify?: (event: AuthSyncNotification) => Promise<void>;
47
143
  }
48
144
  export interface AuthSyncTransport {
49
145
  send: (peer: string, envelope: string) => Promise<void>;
@@ -73,8 +169,10 @@ export declare class CrossNodeAuthSync {
73
169
  private readonly pendingTests;
74
170
  private seenNonces;
75
171
  private timer;
172
+ private importProcessorActive;
76
173
  private activeRemoteLease;
77
174
  private activeLocalLease;
175
+ private lastNotifiedError;
78
176
  private state;
79
177
  constructor(config: AuthSyncConfig, logger: Logger, transport: AuthSyncTransport, callbacks: AuthSyncImportCallbacks);
80
178
  initialize(): Promise<void>;
@@ -99,6 +197,7 @@ export declare class CrossNodeAuthSync {
99
197
  private handleMessage;
100
198
  private handlePullRequest;
101
199
  private handlePullResponse;
200
+ private markPullPeerUnavailable;
102
201
  private handleDigest;
103
202
  private handleLeaseRequest;
104
203
  private handleLeaseReply;
@@ -116,6 +215,9 @@ export declare class CrossNodeAuthSync {
116
215
  private matchConfiguredPeer;
117
216
  private expireLeases;
118
217
  private recordError;
218
+ private rejectImport;
219
+ private notify;
220
+ private notifyError;
119
221
  private writeState;
120
222
  }
121
223
  export declare function readAccessTokenExpiresAtMs(raw: string): number | null;
@@ -25,8 +25,10 @@ export class CrossNodeAuthSync {
25
25
  pendingTests = new Map();
26
26
  seenNonces = new Map();
27
27
  timer = null;
28
+ importProcessorActive = false;
28
29
  activeRemoteLease = null;
29
30
  activeLocalLease = null;
31
+ lastNotifiedError = null;
30
32
  state = {
31
33
  lastSentAt: null,
32
34
  lastReceivedAt: null,
@@ -106,6 +108,7 @@ export class CrossNodeAuthSync {
106
108
  }
107
109
  isIdle() {
108
110
  return this.pendingImports.length === 0
111
+ && !this.importProcessorActive
109
112
  && this.pendingPulls.size === 0
110
113
  && this.pendingLeases.size === 0
111
114
  && this.pendingTests.size === 0;
@@ -116,10 +119,24 @@ export class CrossNodeAuthSync {
116
119
  const record = await this.callbacks.readLocalCandidate(candidateName);
117
120
  if (!record)
118
121
  return false;
119
- await this.sendToAll({
120
- kind: 'push.bundle',
121
- ...bundleFromRecord(record),
122
- });
122
+ this.notify({ kind: 'candidate_publish_started', candidateName, peers: [...this.peers] });
123
+ try {
124
+ await this.sendToAll({
125
+ kind: 'push.bundle',
126
+ ...bundleFromRecord(record),
127
+ });
128
+ this.notify({ kind: 'candidate_publish_completed', candidateName, peers: [...this.peers] });
129
+ }
130
+ catch (error) {
131
+ this.recordError(`candidate publish failed for ${candidateName}: ${formatError(error)}`, false);
132
+ this.notify({
133
+ kind: 'candidate_publish_failed',
134
+ candidateName,
135
+ peers: [...this.peers],
136
+ reason: formatError(error),
137
+ });
138
+ throw error;
139
+ }
123
140
  return true;
124
141
  }
125
142
  async pushAll() {
@@ -127,18 +144,34 @@ export class CrossNodeAuthSync {
127
144
  return { sent: 0, skipped: 0 };
128
145
  let sent = 0;
129
146
  let skipped = 0;
130
- for (const record of await this.callbacks.listLocalCandidates()) {
131
- if (!isAuthCandidateName(record.candidateName)) {
132
- skipped += 1;
133
- continue;
147
+ const records = await this.callbacks.listLocalCandidates();
148
+ this.notify({ kind: 'push_all_started', candidateCount: records.length, peers: [...this.peers] });
149
+ try {
150
+ for (const record of records) {
151
+ if (!isAuthCandidateName(record.candidateName)) {
152
+ skipped += 1;
153
+ continue;
154
+ }
155
+ await this.sendToAll({
156
+ kind: 'push.bundle',
157
+ ...bundleFromRecord(record),
158
+ });
159
+ sent += 1;
134
160
  }
135
- await this.sendToAll({
136
- kind: 'push.bundle',
137
- ...bundleFromRecord(record),
161
+ this.notify({ kind: 'push_all_completed', sent, skipped, peers: [...this.peers] });
162
+ return { sent, skipped };
163
+ }
164
+ catch (error) {
165
+ this.recordError(`push all failed: ${formatError(error)}`, false);
166
+ this.notify({
167
+ kind: 'push_all_failed',
168
+ sent,
169
+ skipped,
170
+ peers: [...this.peers],
171
+ reason: formatError(error),
138
172
  });
139
- sent += 1;
173
+ throw error;
140
174
  }
141
- return { sent, skipped };
142
175
  }
143
176
  async publishDigest() {
144
177
  if (!this.isReady())
@@ -154,18 +187,28 @@ export class CrossNodeAuthSync {
154
187
  if (!this.isReady() || !isAuthCandidateName(candidateName))
155
188
  return false;
156
189
  const requestId = crypto.randomUUID();
190
+ const peers = [...this.peers];
191
+ this.notify({ kind: 'recovery_started', candidateName, peers });
157
192
  const result = await new Promise((resolve) => {
158
193
  const timer = setTimeout(() => {
159
194
  const pending = this.pendingPulls.get(requestId);
160
195
  if (pending) {
161
196
  pending.finished = true;
162
197
  this.pendingPulls.delete(requestId);
198
+ this.notify({
199
+ kind: 'recovery_failed',
200
+ candidateName,
201
+ peers,
202
+ reason: `timed out waiting for ${this.peers.length} auth sync peer response(s)`,
203
+ });
163
204
  resolve(false);
164
205
  }
165
206
  }, PULL_TIMEOUT_MS);
166
207
  timer.unref();
167
208
  this.pendingPulls.set(requestId, {
168
209
  candidateName,
210
+ peers,
211
+ emptyReplies: new Map(),
169
212
  resolve,
170
213
  timer,
171
214
  finished: false,
@@ -179,7 +222,13 @@ export class CrossNodeAuthSync {
179
222
  }).catch((error) => {
180
223
  clearTimeout(timer);
181
224
  this.pendingPulls.delete(requestId);
182
- this.recordError(`pull request failed: ${formatError(error)}`);
225
+ this.recordError(`pull request failed: ${formatError(error)}`, false);
226
+ this.notify({
227
+ kind: 'recovery_failed',
228
+ candidateName,
229
+ peers: [...this.peers],
230
+ reason: formatError(error),
231
+ });
183
232
  resolve(false);
184
233
  });
185
234
  });
@@ -301,7 +350,7 @@ export class CrossNodeAuthSync {
301
350
  this.enqueueImport(message, senderNodeId, sourceLabel, normalizePeerIdentity(peer));
302
351
  return;
303
352
  case 'pull.request':
304
- await this.handlePullRequest(message, normalizePeerIdentity(peer));
353
+ await this.handlePullRequest(message, senderNodeId, normalizePeerIdentity(peer));
305
354
  return;
306
355
  case 'pull.response':
307
356
  await this.handlePullResponse(message, senderNodeId, sourceLabel, normalizePeerIdentity(peer));
@@ -336,20 +385,47 @@ export class CrossNodeAuthSync {
336
385
  return;
337
386
  }
338
387
  }
339
- async handlePullRequest(message, peer) {
388
+ async handlePullRequest(message, requesterNodeId, peer) {
340
389
  if (!isAuthCandidateName(message.candidateName))
341
390
  return;
391
+ this.notify({
392
+ kind: 'pull_request_received',
393
+ candidateName: message.candidateName,
394
+ peer,
395
+ requesterNodeId,
396
+ });
342
397
  const record = await this.callbacks.readLocalCandidate(message.candidateName);
343
398
  if (!record) {
344
399
  await this.sendToPeer(peer, { kind: 'pull.response', requestId: message.requestId, bundle: null, reason: 'candidate not found' });
400
+ this.notify({
401
+ kind: 'pull_response_sent',
402
+ candidateName: message.candidateName,
403
+ peer,
404
+ result: 'candidate_not_found',
405
+ reason: 'candidate not found',
406
+ });
345
407
  return;
346
408
  }
347
409
  if (message.accountId && record.accountId !== message.accountId) {
348
410
  await this.sendToPeer(peer, { kind: 'pull.response', requestId: message.requestId, bundle: null, reason: 'account mismatch' });
411
+ this.notify({
412
+ kind: 'pull_response_sent',
413
+ candidateName: message.candidateName,
414
+ peer,
415
+ result: 'account_mismatch',
416
+ reason: 'account mismatch',
417
+ });
349
418
  return;
350
419
  }
351
420
  if (message.lastRefreshMs !== null && record.lastRefreshMs <= message.lastRefreshMs) {
352
421
  await this.sendToPeer(peer, { kind: 'pull.response', requestId: message.requestId, bundle: null, reason: 'not newer' });
422
+ this.notify({
423
+ kind: 'pull_response_sent',
424
+ candidateName: message.candidateName,
425
+ peer,
426
+ result: 'not_newer',
427
+ reason: 'not newer',
428
+ });
353
429
  return;
354
430
  }
355
431
  await this.sendToPeer(peer, {
@@ -357,22 +433,68 @@ export class CrossNodeAuthSync {
357
433
  requestId: message.requestId,
358
434
  bundle: bundleFromRecord(record),
359
435
  });
436
+ this.notify({
437
+ kind: 'pull_response_sent',
438
+ candidateName: message.candidateName,
439
+ peer,
440
+ result: 'sent',
441
+ reason: null,
442
+ });
360
443
  }
361
444
  async handlePullResponse(message, senderNodeId, sourceLabel, peer) {
362
445
  const pending = this.pendingPulls.get(message.requestId);
363
446
  if (!pending || pending.finished)
364
447
  return;
448
+ const matchedPeer = this.matchConfiguredPeer(peer) ?? peer;
365
449
  if (!message.bundle || message.bundle.candidateName !== pending.candidateName) {
450
+ const reason = message.reason ?? 'peer did not return a matching candidate';
451
+ this.notify({
452
+ kind: 'recovery_peer_empty',
453
+ candidateName: pending.candidateName,
454
+ peer,
455
+ reason,
456
+ });
457
+ this.markPullPeerUnavailable(message.requestId, matchedPeer, reason);
366
458
  return;
367
459
  }
368
- const imported = await this.validateAndImport(message.bundle, senderNodeId, sourceLabel, peer);
369
- if (!imported)
460
+ this.notify({
461
+ kind: 'recovery_peer_bundle_received',
462
+ candidateName: pending.candidateName,
463
+ peer,
464
+ sourceNodeId: senderNodeId,
465
+ });
466
+ const outcome = await this.validateAndImport(message.bundle, senderNodeId, sourceLabel, peer, 'pull');
467
+ if (!outcome.imported) {
468
+ this.markPullPeerUnavailable(message.requestId, matchedPeer, outcome.reason ?? 'peer candidate was not imported');
370
469
  return;
470
+ }
371
471
  pending.finished = true;
372
472
  clearTimeout(pending.timer);
373
473
  this.pendingPulls.delete(message.requestId);
374
474
  pending.resolve(true);
375
475
  }
476
+ markPullPeerUnavailable(requestId, peer, reason) {
477
+ const pending = this.pendingPulls.get(requestId);
478
+ if (!pending || pending.finished)
479
+ return;
480
+ pending.emptyReplies.set(peer, reason);
481
+ if (!pending.peers.every(peerName => pending.emptyReplies.has(peerName))) {
482
+ return;
483
+ }
484
+ pending.finished = true;
485
+ clearTimeout(pending.timer);
486
+ this.pendingPulls.delete(requestId);
487
+ const details = pending.peers
488
+ .map(peerName => `${peerName}: ${pending.emptyReplies.get(peerName) ?? 'no usable candidate'}`)
489
+ .join('; ');
490
+ this.notify({
491
+ kind: 'recovery_failed',
492
+ candidateName: pending.candidateName,
493
+ peers: pending.peers,
494
+ reason: `all peers replied without an importable auth candidate${details ? ` (${details})` : ''}`,
495
+ });
496
+ pending.resolve(false);
497
+ }
376
498
  async handleDigest(message, peer) {
377
499
  const remote = new Map(message.records.map(record => [record.candidateName, record]));
378
500
  for (const local of await this.callbacks.listLocalCandidates()) {
@@ -428,6 +550,7 @@ export class CrossNodeAuthSync {
428
550
  }
429
551
  }
430
552
  enqueueImport(bundle, sourceNodeId, sourceLabel, fromPeer) {
553
+ const queued = this.importProcessorActive || !this.callbacks.isIdle() || this.pendingImports.length > 0;
431
554
  this.pendingImports.push({
432
555
  bundle,
433
556
  sourceNodeId,
@@ -435,49 +558,63 @@ export class CrossNodeAuthSync {
435
558
  receivedAt: Date.now(),
436
559
  fromPeer,
437
560
  });
561
+ this.notify({
562
+ kind: 'remote_bundle_received',
563
+ candidateName: bundle.candidateName,
564
+ sourceNodeId,
565
+ sourceLabel: sourceLabel ?? fromPeer,
566
+ peer: fromPeer,
567
+ queued,
568
+ queueLength: this.pendingImports.length,
569
+ });
438
570
  void this.processPendingImports().catch((error) => {
439
571
  this.recordError(`remote import failed: ${formatError(error)}`);
440
572
  });
441
573
  }
442
574
  async processPendingImports() {
575
+ if (this.importProcessorActive)
576
+ return;
443
577
  if (!this.callbacks.isIdle())
444
578
  return;
445
- while (this.pendingImports.length > 0 && this.callbacks.isIdle()) {
446
- const pending = this.pendingImports.shift();
447
- await this.validateAndImport(pending.bundle, pending.sourceNodeId, pending.sourceLabel, pending.fromPeer);
579
+ this.importProcessorActive = true;
580
+ try {
581
+ while (this.pendingImports.length > 0) {
582
+ if (!this.callbacks.isIdle())
583
+ return;
584
+ const pending = this.pendingImports.shift();
585
+ await this.validateAndImport(pending.bundle, pending.sourceNodeId, pending.sourceLabel, pending.fromPeer, 'push');
586
+ }
587
+ }
588
+ finally {
589
+ this.importProcessorActive = false;
448
590
  }
449
591
  }
450
- async validateAndImport(bundle, sourceNodeId, sourceLabel, fromPeer) {
592
+ async validateAndImport(bundle, sourceNodeId, sourceLabel, fromPeer, mode) {
593
+ const source = sourceLabel ?? fromPeer;
451
594
  if (!isValidBundle(bundle)) {
452
- this.recordError('remote bundle shape is invalid');
453
- return false;
595
+ return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, 'remote bundle shape is invalid');
454
596
  }
455
597
  const metadata = parseChatGptAuthMetadata(bundle.rawAuth);
456
598
  if (!metadata || metadata.accountId !== bundle.accountId || metadata.lastRefreshMs !== bundle.lastRefreshMs) {
457
- this.recordError(`remote bundle metadata mismatch for ${bundle.candidateName}`);
458
- return false;
599
+ return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote bundle metadata mismatch for ${bundle.candidateName}`);
459
600
  }
460
601
  if (sha256(bundle.rawAuth) !== bundle.authSha256) {
461
- this.recordError(`remote bundle hash mismatch for ${bundle.candidateName}`);
462
- return false;
602
+ return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote bundle hash mismatch for ${bundle.candidateName}`);
463
603
  }
464
604
  const expiresAt = readAccessTokenExpiresAtMs(bundle.rawAuth);
465
605
  if (expiresAt === null || expiresAt <= Date.now() + REMOTE_ACCESS_TOKEN_MIN_TTL_MS) {
466
- this.recordError(`remote access token is expired or missing exp for ${bundle.candidateName}`);
467
- return false;
606
+ return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote access token is expired or missing exp for ${bundle.candidateName}`);
468
607
  }
469
608
  const validation = await this.callbacks.validateCandidate(bundle.candidateName, bundle.rawAuth, bundle.accountId);
470
609
  if (!validation.ok) {
471
- this.recordError(`remote candidate validation failed for ${bundle.candidateName}: ${validation.reason ?? 'unknown'}`);
472
- return false;
610
+ return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote candidate validation failed for ${bundle.candidateName}: ${validation.reason ?? 'unknown'}`);
473
611
  }
474
612
  const result = await this.callbacks.importCandidate(bundle.candidateName, bundle.rawAuth, {
475
613
  nodeId: sourceNodeId,
476
- label: sourceLabel ?? fromPeer,
614
+ label: source,
477
615
  });
478
616
  if (!result.ok) {
479
- this.recordError(`remote candidate import failed for ${bundle.candidateName}: ${result.reason ?? 'unknown'}`);
480
- return false;
617
+ return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote candidate import failed for ${bundle.candidateName}: ${result.reason ?? 'unknown'}`);
481
618
  }
482
619
  if (result.imported) {
483
620
  this.state.lastImportedAt = new Date().toISOString();
@@ -485,8 +622,27 @@ export class CrossNodeAuthSync {
485
622
  this.state.lastError = null;
486
623
  await this.writeState();
487
624
  this.logger.info('auth.sync.imported', { candidateName: bundle.candidateName, sourceNodeId });
625
+ this.notify({
626
+ kind: 'remote_import_imported',
627
+ candidateName: bundle.candidateName,
628
+ sourceNodeId,
629
+ sourceLabel: source,
630
+ peer: fromPeer,
631
+ mode,
632
+ });
488
633
  }
489
- return result.imported;
634
+ else {
635
+ this.notify({
636
+ kind: 'remote_import_skipped',
637
+ candidateName: bundle.candidateName,
638
+ sourceNodeId,
639
+ sourceLabel: source,
640
+ peer: fromPeer,
641
+ mode,
642
+ reason: result.reason ?? 'local candidate did not need an update',
643
+ });
644
+ }
645
+ return { ok: true, imported: result.imported, reason: result.reason ?? null };
490
646
  }
491
647
  async sendToAll(message) {
492
648
  await Promise.all(this.peers.map(peer => this.sendToPeer(peer, message)));
@@ -601,12 +757,41 @@ export class CrossNodeAuthSync {
601
757
  this.activeLocalLease = null;
602
758
  }
603
759
  }
604
- recordError(message) {
760
+ recordError(message, notify = true) {
605
761
  this.state.lastError = message;
606
762
  this.logger.warn('auth.sync.error', { error: message });
607
763
  void this.writeState().catch((error) => {
608
764
  this.logger.warn('auth.sync.state_write_failed', { error: formatError(error) });
609
765
  });
766
+ if (notify) {
767
+ this.notifyError(message);
768
+ }
769
+ }
770
+ rejectImport(bundle, sourceNodeId, sourceLabel, fromPeer, mode, reason) {
771
+ this.recordError(reason, false);
772
+ this.notify({
773
+ kind: 'remote_import_failed',
774
+ candidateName: bundle.candidateName,
775
+ sourceNodeId,
776
+ sourceLabel,
777
+ peer: fromPeer,
778
+ mode,
779
+ reason,
780
+ });
781
+ return { ok: false, imported: false, reason };
782
+ }
783
+ notify(event) {
784
+ if (!this.callbacks.notify)
785
+ return;
786
+ void this.callbacks.notify(event).catch((error) => {
787
+ this.logger.warn('auth.sync.notify_failed', { error: formatError(error) });
788
+ });
789
+ }
790
+ notifyError(reason) {
791
+ if (this.lastNotifiedError === reason)
792
+ return;
793
+ this.lastNotifiedError = reason;
794
+ this.notify({ kind: 'sync_error', reason });
610
795
  }
611
796
  async writeState() {
612
797
  if (!this.config.enabled)
package/dist/main.js CHANGED
@@ -375,6 +375,7 @@ async function runServeCli() {
375
375
  },
376
376
  importCandidate: (candidateName, raw, source) => mirror.importExternalCandidate(candidateName, raw, source),
377
377
  isIdle: authSyncLocalIdle,
378
+ notify: createAuthSyncNotifier(store, authSyncTransportBot.bot),
378
379
  });
379
380
  await authSync.initialize();
380
381
  activeAuthSync = authSync;
@@ -488,6 +489,7 @@ async function runServeCli() {
488
489
  }
489
490
  core = new BridgeSessionCore(config, store, logger, bot, app, outbound, selfUpdater, singleCoordinator);
490
491
  if (config.authSyncEnabled && singleMirror) {
492
+ await bot.initializeIdentity();
491
493
  singleAuthSync = new CrossNodeAuthSync(buildAuthSyncConfig(config, bot.username ? `@${bot.username}` : 'default'), logger, {
492
494
  send: async (peer, envelope) => {
493
495
  await bot.sendDocument(peer, `foxclaw-auth-sync-${Date.now()}.json`, Buffer.from(envelope, 'utf8'), 'FOXCLAW_AUTH_SYNC_V1');
@@ -503,6 +505,7 @@ async function runServeCli() {
503
505
  },
504
506
  importCandidate: (candidateName, raw, source) => singleMirror.importExternalCandidate(candidateName, raw, source),
505
507
  isIdle: singleAuthSyncLocalIdle,
508
+ notify: createAuthSyncNotifier(store, bot),
506
509
  });
507
510
  await singleAuthSync.initialize();
508
511
  activeAuthSync = singleAuthSync;
@@ -569,6 +572,133 @@ async function runServeCli() {
569
572
  throw error;
570
573
  }
571
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
+ }
572
702
  const AUTH_SYNC_TELEGRAM_CAPTION = 'FOXCLAW_AUTH_SYNC_V1';
573
703
  function buildAuthSyncConfig(config, transportLabel = null) {
574
704
  return {
@@ -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)
@@ -166,6 +166,8 @@ Confirm that pending imports were processed, or that the candidate exists or has
166
166
 
167
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
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
+
169
171
  5. Only test refresh-token rotation after you understand the risk:
170
172
 
171
173
  ```text
@@ -419,7 +419,7 @@ 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. 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`.
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
 
@@ -166,6 +166,8 @@ auth sync 测试完成:已发送 1,收到回应 1。
166
166
 
167
167
  注意:`/auth sync push all` 的“已发送”只代表本节点把加密包发给 Telegram 成功,不代表对端已经写盘。对端只有在全局空闲、usage 验证通过、同名候选 account id 一致,并且远端 `last_refresh` 比本地更新时才会覆盖文件。如果本地已经是相同或更新版本,文件不会变化,`最近导入` 也可能保持为空。
168
168
 
169
+ 启用跨节点同步后,联系人 bot 的私聊会收到节点级通知:本机 auth 更新并开始发往哪些 peer、收到远端包后是排队还是立即验证、导入成功/跳过/失败原因、auth 恢复时正在查询哪些 peer、peer 回应了什么,以及所有 peer 都无法提供可用副本时的人工介入提示。通知不会包含 auth 内容、token 或同步密文。
170
+
169
171
  5. 只有在完全理解 refresh token 轮换风险时,才测试:
170
172
 
171
173
  ```text
@@ -419,7 +419,7 @@ 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。推荐每台机器选择一个联系人 bot;同一节点内其他 bot 继续使用本机 auth 镜像。多 bot 模式下,默认联系人是 `TG_BOT_TOKENS` 的第一个 token
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.4.17",
3
+ "version": "0.5.1",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",