@foxden-app/foxclaw 0.5.18 → 0.5.19
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 +10 -0
- package/README.md +1 -1
- package/README_EN.md +1 -1
- package/dist/auth/mirror.d.ts +12 -1
- package/dist/auth/mirror.js +14 -4
- package/dist/auth/notifications.d.ts +27 -0
- package/dist/auth/notifications.js +235 -0
- package/dist/controller/controller.d.ts +1 -0
- package/dist/controller/controller.js +23 -4
- package/dist/main.js +33 -9
- package/docs/cross-node-auth-sync.md +1 -1
- package/docs/user-manual.md +2 -2
- package/docs/zh/cross-node-auth-sync.md +1 -1
- package/docs/zh/user-manual.md +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
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.19 - 2026-06-08
|
|
6
|
+
|
|
7
|
+
### 中文
|
|
8
|
+
- 主动后台 auth 刷新现在只保留一条私聊状态消息:开始时发送,完成或拿不到刷新锁时编辑为最终结果,减少开始/完成两条消息的打扰。
|
|
9
|
+
- 本机 auth 镜像和跨节点 auth 同步的刷新 burst 现在会短窗口汇总,把候选镜像、跨节点发送、收到远端包、导入/跳过/失败合成摘要;恢复失败和人工介入提示仍会明确发出。
|
|
10
|
+
|
|
11
|
+
### English
|
|
12
|
+
- Background proactive auth refresh now keeps one private status message: it sends the starting state and edits that message to the final result or lease failure, reducing separate start/done notifications.
|
|
13
|
+
- Same-node auth mirroring and cross-node auth sync now group refresh bursts into short summaries covering mirror writes, peer sends, received remote bundles, import/skip/failure results, while recovery failures and manual-intervention notices remain explicit.
|
|
14
|
+
|
|
5
15
|
## 0.5.18 - 2026-06-08
|
|
6
16
|
|
|
7
17
|
### 中文
|
package/README.md
CHANGED
|
@@ -216,7 +216,7 @@ TG_BOT_TOKEN=123456:token_a
|
|
|
216
216
|
|
|
217
217
|
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 摘要。
|
|
218
218
|
|
|
219
|
-
多台机器共享同一合法账号池时,可以启用可选跨节点 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
|
|
219
|
+
多台机器共享同一合法账号池时,可以启用可选跨节点 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)。
|
|
220
220
|
|
|
221
221
|
如果你需要一路 Telegram 与终端互通 session,把同一个 token 同时填入 `TG_BOT_TOKENS` 和 `TG_BOT_TOKEN`。这个 bot 使用默认 `CODEX_HOME`(未设置时通常是 `~/.codex`)和默认 auth,因此能看到终端 Codex 的本地线程;它不再享有隔离 runtime 的“互不影响”保证,切换 auth 会影响终端和其他默认 runtime。
|
|
222
222
|
|
package/README_EN.md
CHANGED
|
@@ -216,7 +216,7 @@ TG_BOT_TOKEN=123456:token_a
|
|
|
216
216
|
|
|
217
217
|
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.
|
|
218
218
|
|
|
219
|
-
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
|
+
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 grouped 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).
|
|
220
220
|
|
|
221
221
|
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.
|
|
222
222
|
|
package/dist/auth/mirror.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export interface AuthMirrorRuntime {
|
|
|
3
3
|
id: string;
|
|
4
4
|
label?: string;
|
|
5
5
|
authDir: string;
|
|
6
|
-
notify?: (
|
|
6
|
+
notify?: (event: AuthMirrorNotification) => Promise<void>;
|
|
7
7
|
validate?: (context: AuthMirrorValidationContext) => Promise<AuthMirrorValidationResult | boolean>;
|
|
8
8
|
}
|
|
9
9
|
export interface AuthMirrorStatus {
|
|
@@ -62,6 +62,17 @@ export interface AuthMirrorSyncAllResult {
|
|
|
62
62
|
export interface AuthMirrorHooks {
|
|
63
63
|
onSynced?: (event: AuthMirrorSyncedEvent) => Promise<void> | void;
|
|
64
64
|
}
|
|
65
|
+
export type AuthMirrorNotification = {
|
|
66
|
+
kind: 'local_synced';
|
|
67
|
+
candidateName: string;
|
|
68
|
+
sourceRuntimeId: string;
|
|
69
|
+
sourceLabel: string;
|
|
70
|
+
} | {
|
|
71
|
+
kind: 'remote_imported';
|
|
72
|
+
candidateName: string;
|
|
73
|
+
sourceNodeId: string;
|
|
74
|
+
sourceLabel: string;
|
|
75
|
+
};
|
|
65
76
|
export declare class AuthCandidateMirror {
|
|
66
77
|
private readonly canonicalDir;
|
|
67
78
|
private readonly runtimes;
|
package/dist/auth/mirror.js
CHANGED
|
@@ -297,8 +297,13 @@ export class AuthCandidateMirror {
|
|
|
297
297
|
};
|
|
298
298
|
await writeMirrorStatus(this.statusPath, this.lastStatus);
|
|
299
299
|
this.logger.info('auth.mirror.remote_imported', { candidateName, sourceNodeId: source.nodeId });
|
|
300
|
-
const
|
|
301
|
-
|
|
300
|
+
const notification = {
|
|
301
|
+
kind: 'remote_imported',
|
|
302
|
+
candidateName,
|
|
303
|
+
sourceNodeId: source.nodeId,
|
|
304
|
+
sourceLabel,
|
|
305
|
+
};
|
|
306
|
+
await Promise.allSettled(this.runtimes.map((target) => target.notify?.(notification)));
|
|
302
307
|
return {
|
|
303
308
|
ok: true,
|
|
304
309
|
imported: true,
|
|
@@ -382,8 +387,13 @@ export class AuthCandidateMirror {
|
|
|
382
387
|
};
|
|
383
388
|
await writeMirrorStatus(this.statusPath, this.lastStatus);
|
|
384
389
|
this.logger.info('auth.mirror.synced', { sourceRuntimeId: runtime.id, name });
|
|
385
|
-
const
|
|
386
|
-
|
|
390
|
+
const notification = {
|
|
391
|
+
kind: 'local_synced',
|
|
392
|
+
candidateName: name,
|
|
393
|
+
sourceRuntimeId: runtime.id,
|
|
394
|
+
sourceLabel,
|
|
395
|
+
};
|
|
396
|
+
await Promise.allSettled(this.runtimes.map((target) => target.notify?.(notification)));
|
|
387
397
|
await this.hooks.onSynced?.({
|
|
388
398
|
status: this.lastStatus,
|
|
389
399
|
record: {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { AppLocale } from '../types.js';
|
|
2
|
+
import type { AuthSyncNotification } from './cross_node_sync.js';
|
|
3
|
+
import type { AuthMirrorNotification } from './mirror.js';
|
|
4
|
+
export type AuthRefreshNotificationItem = {
|
|
5
|
+
source: 'mirror';
|
|
6
|
+
event: AuthMirrorNotification;
|
|
7
|
+
} | {
|
|
8
|
+
source: 'auth_sync';
|
|
9
|
+
event: AuthSyncNotification;
|
|
10
|
+
};
|
|
11
|
+
export interface AuthRefreshNotificationDestination {
|
|
12
|
+
key: string;
|
|
13
|
+
locale: AppLocale;
|
|
14
|
+
sendMessage: (text: string) => Promise<unknown>;
|
|
15
|
+
}
|
|
16
|
+
export interface AuthRefreshNotificationAggregator {
|
|
17
|
+
enqueueMirror(destination: AuthRefreshNotificationDestination, event: AuthMirrorNotification): void;
|
|
18
|
+
enqueueAuthSync(destination: AuthRefreshNotificationDestination, event: AuthSyncNotification): boolean;
|
|
19
|
+
flushAll(): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
interface AuthNotificationLogger {
|
|
22
|
+
warn(message: string, meta?: unknown): void;
|
|
23
|
+
}
|
|
24
|
+
export declare function createAuthRefreshNotificationAggregator(logger: AuthNotificationLogger, delayMs?: number): AuthRefreshNotificationAggregator;
|
|
25
|
+
export declare function isAggregateableAuthSyncNotification(event: AuthSyncNotification): boolean;
|
|
26
|
+
export declare function formatAggregatedAuthRefreshNotifications(locale: AppLocale, items: AuthRefreshNotificationItem[]): string | null;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
const AUTH_REFRESH_NOTIFICATION_AGGREGATE_DELAY_MS = 8_000;
|
|
2
|
+
const AUTH_REFRESH_NOTIFICATION_LIST_LIMIT = 8;
|
|
3
|
+
export function createAuthRefreshNotificationAggregator(logger, delayMs = AUTH_REFRESH_NOTIFICATION_AGGREGATE_DELAY_MS) {
|
|
4
|
+
const queues = new Map();
|
|
5
|
+
const flush = async (key) => {
|
|
6
|
+
const queue = queues.get(key);
|
|
7
|
+
if (!queue)
|
|
8
|
+
return;
|
|
9
|
+
if (queue.timer) {
|
|
10
|
+
clearTimeout(queue.timer);
|
|
11
|
+
queue.timer = null;
|
|
12
|
+
}
|
|
13
|
+
queues.delete(key);
|
|
14
|
+
const message = formatAggregatedAuthRefreshNotifications(queue.locale, queue.items);
|
|
15
|
+
if (!message)
|
|
16
|
+
return;
|
|
17
|
+
try {
|
|
18
|
+
await queue.sendMessage(message);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
logger.warn('auth.notification_aggregate_send_failed', { error: formatError(error) });
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const enqueue = (destination, item) => {
|
|
25
|
+
const existing = queues.get(destination.key);
|
|
26
|
+
const queue = existing ?? {
|
|
27
|
+
locale: destination.locale,
|
|
28
|
+
sendMessage: destination.sendMessage,
|
|
29
|
+
items: [],
|
|
30
|
+
timer: null,
|
|
31
|
+
};
|
|
32
|
+
queue.locale = destination.locale;
|
|
33
|
+
queue.sendMessage = destination.sendMessage;
|
|
34
|
+
queue.items.push(item);
|
|
35
|
+
if (queue.timer) {
|
|
36
|
+
clearTimeout(queue.timer);
|
|
37
|
+
}
|
|
38
|
+
queue.timer = setTimeout(() => {
|
|
39
|
+
void flush(destination.key);
|
|
40
|
+
}, delayMs);
|
|
41
|
+
queue.timer.unref();
|
|
42
|
+
queues.set(destination.key, queue);
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
enqueueMirror(destination, event) {
|
|
46
|
+
enqueue(destination, { source: 'mirror', event });
|
|
47
|
+
},
|
|
48
|
+
enqueueAuthSync(destination, event) {
|
|
49
|
+
if (!isAggregateableAuthSyncNotification(event)) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
enqueue(destination, { source: 'auth_sync', event });
|
|
53
|
+
return true;
|
|
54
|
+
},
|
|
55
|
+
async flushAll() {
|
|
56
|
+
await Promise.all([...queues.keys()].map((key) => flush(key)));
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function isAggregateableAuthSyncNotification(event) {
|
|
61
|
+
switch (event.kind) {
|
|
62
|
+
case 'candidate_publish_started':
|
|
63
|
+
case 'candidate_publish_completed':
|
|
64
|
+
case 'candidate_publish_failed':
|
|
65
|
+
case 'remote_bundle_received':
|
|
66
|
+
case 'remote_import_imported':
|
|
67
|
+
case 'remote_import_skipped':
|
|
68
|
+
case 'remote_import_failed':
|
|
69
|
+
return true;
|
|
70
|
+
default:
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function formatAggregatedAuthRefreshNotifications(locale, items) {
|
|
75
|
+
const localSynced = [];
|
|
76
|
+
const mirrorRemoteImports = [];
|
|
77
|
+
const publishes = new Map();
|
|
78
|
+
const remoteBundles = new Map();
|
|
79
|
+
const remoteImports = new Map();
|
|
80
|
+
for (const item of items) {
|
|
81
|
+
if (item.source === 'mirror') {
|
|
82
|
+
if (item.event.kind === 'local_synced') {
|
|
83
|
+
localSynced.push({
|
|
84
|
+
candidateName: item.event.candidateName,
|
|
85
|
+
sourceLabel: item.event.sourceLabel,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
mirrorRemoteImports.push({
|
|
90
|
+
candidateName: item.event.candidateName,
|
|
91
|
+
sourceNodeId: item.event.sourceNodeId,
|
|
92
|
+
sourceLabel: item.event.sourceLabel,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const event = item.event;
|
|
98
|
+
switch (event.kind) {
|
|
99
|
+
case 'candidate_publish_started':
|
|
100
|
+
publishes.set(event.candidateName, {
|
|
101
|
+
candidateName: event.candidateName,
|
|
102
|
+
peers: event.peers,
|
|
103
|
+
state: 'sending',
|
|
104
|
+
reason: null,
|
|
105
|
+
});
|
|
106
|
+
break;
|
|
107
|
+
case 'candidate_publish_completed':
|
|
108
|
+
publishes.set(event.candidateName, {
|
|
109
|
+
candidateName: event.candidateName,
|
|
110
|
+
peers: event.peers,
|
|
111
|
+
state: 'sent',
|
|
112
|
+
reason: null,
|
|
113
|
+
});
|
|
114
|
+
break;
|
|
115
|
+
case 'candidate_publish_failed':
|
|
116
|
+
publishes.set(event.candidateName, {
|
|
117
|
+
candidateName: event.candidateName,
|
|
118
|
+
peers: event.peers,
|
|
119
|
+
state: 'failed',
|
|
120
|
+
reason: event.reason,
|
|
121
|
+
});
|
|
122
|
+
break;
|
|
123
|
+
case 'remote_bundle_received':
|
|
124
|
+
remoteBundles.set(remoteKey(event), {
|
|
125
|
+
candidateName: event.candidateName,
|
|
126
|
+
sourceNodeId: event.sourceNodeId,
|
|
127
|
+
sourceLabel: event.sourceLabel,
|
|
128
|
+
peer: event.peer,
|
|
129
|
+
queued: event.queued,
|
|
130
|
+
});
|
|
131
|
+
break;
|
|
132
|
+
case 'remote_import_imported':
|
|
133
|
+
case 'remote_import_skipped':
|
|
134
|
+
case 'remote_import_failed':
|
|
135
|
+
remoteImports.set(remoteKey(event), {
|
|
136
|
+
candidateName: event.candidateName,
|
|
137
|
+
sourceNodeId: event.sourceNodeId,
|
|
138
|
+
sourceLabel: event.sourceLabel,
|
|
139
|
+
peer: event.peer,
|
|
140
|
+
state: event.kind === 'remote_import_imported'
|
|
141
|
+
? 'imported'
|
|
142
|
+
: event.kind === 'remote_import_skipped'
|
|
143
|
+
? 'skipped'
|
|
144
|
+
: 'failed',
|
|
145
|
+
reason: 'reason' in event ? event.reason : null,
|
|
146
|
+
});
|
|
147
|
+
break;
|
|
148
|
+
default:
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const lines = [locale === 'zh' ? 'auth 刷新/同步汇总' : 'Auth refresh/sync summary'];
|
|
153
|
+
const failedPublishes = [...publishes.values()].filter((entry) => entry.state === 'failed');
|
|
154
|
+
const failedImports = [...remoteImports.values()].filter((entry) => entry.state === 'failed');
|
|
155
|
+
const sentPublishes = [...publishes.values()].filter((entry) => entry.state === 'sent');
|
|
156
|
+
const sendingPublishes = [...publishes.values()].filter((entry) => entry.state === 'sending');
|
|
157
|
+
const imported = [...remoteImports.values()].filter((entry) => entry.state === 'imported');
|
|
158
|
+
const skipped = [...remoteImports.values()].filter((entry) => entry.state === 'skipped');
|
|
159
|
+
const pendingRemote = [...remoteBundles.entries()]
|
|
160
|
+
.filter(([key]) => !remoteImports.has(key))
|
|
161
|
+
.map(([, entry]) => entry);
|
|
162
|
+
const coveredRemoteImportKeys = new Set([...remoteImports.values()]
|
|
163
|
+
.map((entry) => `${entry.candidateName}\0${entry.sourceNodeId}`));
|
|
164
|
+
const mirrorRemoteOnly = mirrorRemoteImports
|
|
165
|
+
.filter((entry) => !coveredRemoteImportKeys.has(`${entry.candidateName}\0${entry.sourceNodeId}`));
|
|
166
|
+
if (failedPublishes.length > 0) {
|
|
167
|
+
lines.push(formatLine(locale, 'Cross-node send failed', '跨节点发送失败', failedPublishes.map((entry) => `${entry.candidateName} -> ${formatPeerList(entry.peers, locale)} (${clipInline(entry.reason ?? '')})`)));
|
|
168
|
+
}
|
|
169
|
+
if (failedImports.length > 0) {
|
|
170
|
+
lines.push(formatLine(locale, 'Cross-node import failed', '跨节点导入失败', failedImports.map((entry) => `${entry.candidateName} <- ${formatSource(entry.sourceLabel, entry.sourceNodeId)} (${clipInline(entry.reason ?? '')})`)));
|
|
171
|
+
}
|
|
172
|
+
if (localSynced.length > 0) {
|
|
173
|
+
lines.push(formatLine(locale, 'Same-node mirror', '同节点镜像', dedupe(localSynced.map((entry) => `${entry.candidateName} <- ${entry.sourceLabel}`))));
|
|
174
|
+
}
|
|
175
|
+
if (sentPublishes.length > 0) {
|
|
176
|
+
lines.push(formatLine(locale, 'Cross-node sent', '跨节点已发送', sentPublishes.map((entry) => `${entry.candidateName} -> ${formatPeerList(entry.peers, locale)}`)));
|
|
177
|
+
}
|
|
178
|
+
if (sendingPublishes.length > 0) {
|
|
179
|
+
lines.push(formatLine(locale, 'Cross-node sending', '跨节点发送中', sendingPublishes.map((entry) => `${entry.candidateName} -> ${formatPeerList(entry.peers, locale)}`)));
|
|
180
|
+
}
|
|
181
|
+
if (imported.length > 0) {
|
|
182
|
+
lines.push(formatLine(locale, 'Cross-node imported', '跨节点已导入', imported.map((entry) => `${entry.candidateName} <- ${formatSource(entry.sourceLabel, entry.sourceNodeId)}`)));
|
|
183
|
+
}
|
|
184
|
+
if (skipped.length > 0) {
|
|
185
|
+
lines.push(formatLine(locale, 'Cross-node skipped', '跨节点已跳过', skipped.map((entry) => `${entry.candidateName} <- ${formatSource(entry.sourceLabel, entry.sourceNodeId)} (${clipInline(entry.reason ?? '')})`)));
|
|
186
|
+
}
|
|
187
|
+
if (pendingRemote.length > 0) {
|
|
188
|
+
lines.push(formatLine(locale, 'Cross-node pending', '跨节点待处理', pendingRemote.map((entry) => {
|
|
189
|
+
const state = locale === 'zh'
|
|
190
|
+
? (entry.queued ? '已排队' : '验证中')
|
|
191
|
+
: (entry.queued ? 'queued' : 'validating');
|
|
192
|
+
return `${entry.candidateName} <- ${formatSource(entry.sourceLabel, entry.sourceNodeId)} (${state})`;
|
|
193
|
+
})));
|
|
194
|
+
}
|
|
195
|
+
if (mirrorRemoteOnly.length > 0) {
|
|
196
|
+
lines.push(formatLine(locale, 'Remote mirror write', '远端镜像写入', mirrorRemoteOnly.map((entry) => `${entry.candidateName} <- ${formatSource(entry.sourceLabel, entry.sourceNodeId)}`)));
|
|
197
|
+
}
|
|
198
|
+
return lines.length > 1 ? lines.join('\n') : null;
|
|
199
|
+
}
|
|
200
|
+
function remoteKey(event) {
|
|
201
|
+
return `${event.candidateName}\0${event.sourceNodeId}\0${event.peer}\0${event.mode ?? 'push'}`;
|
|
202
|
+
}
|
|
203
|
+
function formatLine(locale, enLabel, zhLabel, values) {
|
|
204
|
+
return `${locale === 'zh' ? zhLabel : enLabel}: ${formatCompactList(values, locale)}`;
|
|
205
|
+
}
|
|
206
|
+
function formatCompactList(values, locale) {
|
|
207
|
+
const unique = dedupe(values).filter(Boolean);
|
|
208
|
+
if (unique.length <= AUTH_REFRESH_NOTIFICATION_LIST_LIMIT) {
|
|
209
|
+
return unique.join(', ');
|
|
210
|
+
}
|
|
211
|
+
const shown = unique.slice(0, AUTH_REFRESH_NOTIFICATION_LIST_LIMIT).join(', ');
|
|
212
|
+
const remaining = unique.length - AUTH_REFRESH_NOTIFICATION_LIST_LIMIT;
|
|
213
|
+
return locale === 'zh'
|
|
214
|
+
? `${shown},另 ${remaining} 项`
|
|
215
|
+
: `${shown}, and ${remaining} more`;
|
|
216
|
+
}
|
|
217
|
+
function formatPeerList(peers, locale) {
|
|
218
|
+
return peers.length > 0 ? peers.join(', ') : (locale === 'zh' ? '无' : 'none');
|
|
219
|
+
}
|
|
220
|
+
function formatSource(sourceLabel, sourceNodeId) {
|
|
221
|
+
return sourceLabel === sourceNodeId ? sourceNodeId : `${sourceLabel} / ${sourceNodeId}`;
|
|
222
|
+
}
|
|
223
|
+
function dedupe(values) {
|
|
224
|
+
return [...new Set(values)];
|
|
225
|
+
}
|
|
226
|
+
function clipInline(value, limit = 120) {
|
|
227
|
+
const clean = value.replace(/\s+/g, ' ').trim();
|
|
228
|
+
if (clean.length <= limit) {
|
|
229
|
+
return clean;
|
|
230
|
+
}
|
|
231
|
+
return `${clean.slice(0, Math.max(0, limit - 3))}...`;
|
|
232
|
+
}
|
|
233
|
+
function formatError(error) {
|
|
234
|
+
return error instanceof Error ? error.message : String(error);
|
|
235
|
+
}
|
|
@@ -87,6 +87,7 @@ export declare class BridgeSessionCore {
|
|
|
87
87
|
private selfUpdatePollTimer;
|
|
88
88
|
private proactiveAuthRefreshTimer;
|
|
89
89
|
private proactiveAuthRefreshInProgress;
|
|
90
|
+
private proactiveAuthRefreshStatusMessage;
|
|
90
91
|
private attachedThreads;
|
|
91
92
|
private botUsername;
|
|
92
93
|
private lastError;
|
|
@@ -136,6 +136,7 @@ export class BridgeSessionCore {
|
|
|
136
136
|
selfUpdatePollTimer = null;
|
|
137
137
|
proactiveAuthRefreshTimer = null;
|
|
138
138
|
proactiveAuthRefreshInProgress = false;
|
|
139
|
+
proactiveAuthRefreshStatusMessage = null;
|
|
139
140
|
attachedThreads = new Set();
|
|
140
141
|
botUsername = null;
|
|
141
142
|
lastError = null;
|
|
@@ -4388,11 +4389,11 @@ export class BridgeSessionCore {
|
|
|
4388
4389
|
this.logger.warn('codex.auth_proactive_refresh_lease_failed', { reason: lease.reason });
|
|
4389
4390
|
await this.notifyProactiveAuthRefresh(locale, t(locale, 'auth_proactive_refresh_lease_failed', {
|
|
4390
4391
|
error: lease.reason ?? t(locale, 'unknown'),
|
|
4391
|
-
}));
|
|
4392
|
+
}), true);
|
|
4392
4393
|
return;
|
|
4393
4394
|
}
|
|
4394
4395
|
const result = await this.refreshCodexAuthCandidates(new Set(dueCandidates.map(candidate => candidate.name)));
|
|
4395
|
-
await this.notifyProactiveAuthRefresh(locale, formatAuthRefreshAllResult(locale, result, 'proactive'));
|
|
4396
|
+
await this.notifyProactiveAuthRefresh(locale, formatAuthRefreshAllResult(locale, result, 'proactive'), true);
|
|
4396
4397
|
}
|
|
4397
4398
|
finally {
|
|
4398
4399
|
await this.coordinator?.releaseAuthRefreshLease?.(lease?.leaseId ?? null);
|
|
@@ -4408,14 +4409,32 @@ export class BridgeSessionCore {
|
|
|
4408
4409
|
return 'en';
|
|
4409
4410
|
return this.localeForChat(privateScope.scopeId);
|
|
4410
4411
|
}
|
|
4411
|
-
async notifyProactiveAuthRefresh(locale, message) {
|
|
4412
|
+
async notifyProactiveAuthRefresh(locale, message, final = false) {
|
|
4412
4413
|
const identity = this.bot.identity;
|
|
4413
4414
|
if (!identity)
|
|
4414
4415
|
return;
|
|
4415
4416
|
const privateScope = this.store.getTelegramPrivateScope(identity);
|
|
4416
4417
|
if (!privateScope)
|
|
4417
4418
|
return;
|
|
4418
|
-
|
|
4419
|
+
const previous = this.proactiveAuthRefreshStatusMessage;
|
|
4420
|
+
if (previous?.chatId === privateScope.chatId) {
|
|
4421
|
+
try {
|
|
4422
|
+
await this.bot.editMessage(privateScope.chatId, previous.messageId, message, []);
|
|
4423
|
+
if (final) {
|
|
4424
|
+
this.proactiveAuthRefreshStatusMessage = null;
|
|
4425
|
+
}
|
|
4426
|
+
return;
|
|
4427
|
+
}
|
|
4428
|
+
catch (error) {
|
|
4429
|
+
this.logger.warn('codex.auth_proactive_refresh_notify_edit_failed', { error: toErrorMeta(error) });
|
|
4430
|
+
}
|
|
4431
|
+
}
|
|
4432
|
+
await this.bot.sendMessage(privateScope.chatId, message).then((messageId) => {
|
|
4433
|
+
this.proactiveAuthRefreshStatusMessage = final ? null : { chatId: privateScope.chatId, messageId };
|
|
4434
|
+
}).catch((error) => {
|
|
4435
|
+
if (final) {
|
|
4436
|
+
this.proactiveAuthRefreshStatusMessage = null;
|
|
4437
|
+
}
|
|
4419
4438
|
this.logger.warn('codex.auth_proactive_refresh_notify_failed', { error: toErrorMeta(error) });
|
|
4420
4439
|
});
|
|
4421
4440
|
}
|
package/dist/main.js
CHANGED
|
@@ -8,6 +8,7 @@ import { createInterface } from 'node:readline/promises';
|
|
|
8
8
|
import { spawnSync } from 'node:child_process';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import { APP_HOME, DEFAULT_CODEX_TELEGRAM_HOME, DEFAULT_ENV_PATH, DEFAULT_LOG_PATH, DEFAULT_STATUS_PATH, getLoadedEnvPath, loadConfig, loadEnv, } from './config.js';
|
|
11
|
+
import { createAuthRefreshNotificationAggregator, } from './auth/notifications.js';
|
|
11
12
|
import { acquireProcessLock, LockHeldError } from './lock.js';
|
|
12
13
|
import { readRuntimeStatus, writeRuntimeStatus } from './runtime.js';
|
|
13
14
|
import { buildFoxclawSystemdUnitText, buildSystemdRestartHelperArgs, cgroupContainsSystemdUnit, refreshFoxclawExecStartDropIns, removeFoxclawExecStartDropIns, } from './systemd.js';
|
|
@@ -196,6 +197,7 @@ async function runServeCli() {
|
|
|
196
197
|
]);
|
|
197
198
|
const config = loadConfig();
|
|
198
199
|
const logger = new Logger(config.logLevel, config.logPath);
|
|
200
|
+
const authNotificationAggregator = createAuthRefreshNotificationAggregator(logger);
|
|
199
201
|
attachIlinkRuntimeFromBridgeLogger(logger, config.wxIlinkRouteTag);
|
|
200
202
|
const processLock = acquireProcessLock(config.lockPath);
|
|
201
203
|
let store = null;
|
|
@@ -251,12 +253,7 @@ async function runServeCli() {
|
|
|
251
253
|
label: runtime.bot.username ? `@${runtime.bot.username}` : runtime.id,
|
|
252
254
|
authDir: runtime.authDir,
|
|
253
255
|
validate: async (context) => validateRefreshedAuthCandidate(runtime, context.candidateName),
|
|
254
|
-
notify:
|
|
255
|
-
const chatId = store.getTelegramPrivateChatId(runtime.id);
|
|
256
|
-
if (chatId) {
|
|
257
|
-
await runtime.bot.sendMessage(chatId, message);
|
|
258
|
-
}
|
|
259
|
-
},
|
|
256
|
+
notify: createAuthMirrorNotifier(store, runtime.id, runtime.bot, authNotificationAggregator),
|
|
260
257
|
})), logger, path.join(APP_HOME, 'runtime', 'auth-mirror.json'), {
|
|
261
258
|
onSynced: async (event) => {
|
|
262
259
|
await authSync?.publishCandidate(event.record.candidateName);
|
|
@@ -428,7 +425,7 @@ async function runServeCli() {
|
|
|
428
425
|
},
|
|
429
426
|
importCandidate: (candidateName, raw, source) => mirror.importExternalCandidate(candidateName, raw, source),
|
|
430
427
|
isIdle: authSyncLocalIdle,
|
|
431
|
-
notify: createAuthSyncNotifier(store, authSyncTransportBot.bot),
|
|
428
|
+
notify: createAuthSyncNotifier(store, authSyncTransportBot.bot, authNotificationAggregator),
|
|
432
429
|
});
|
|
433
430
|
await authSync.initialize();
|
|
434
431
|
activeAuthSync = authSync;
|
|
@@ -453,6 +450,7 @@ async function runServeCli() {
|
|
|
453
450
|
logger.info('bridge.started', { bots: runtimes.map((runtime) => runtime.id) });
|
|
454
451
|
const shutdown = async (signal) => {
|
|
455
452
|
logger.info('bridge.shutting_down', { signal });
|
|
453
|
+
await authNotificationAggregator.flushAll();
|
|
456
454
|
authSync?.stop();
|
|
457
455
|
mirror.stop();
|
|
458
456
|
await weixinAdapter?.stop();
|
|
@@ -573,7 +571,7 @@ async function runServeCli() {
|
|
|
573
571
|
},
|
|
574
572
|
importCandidate: (candidateName, raw, source) => singleMirror.importExternalCandidate(candidateName, raw, source),
|
|
575
573
|
isIdle: singleAuthSyncLocalIdle,
|
|
576
|
-
notify: createAuthSyncNotifier(store, bot),
|
|
574
|
+
notify: createAuthSyncNotifier(store, bot, authNotificationAggregator),
|
|
577
575
|
});
|
|
578
576
|
await singleAuthSync.initialize();
|
|
579
577
|
activeAuthSync = singleAuthSync;
|
|
@@ -600,6 +598,7 @@ async function runServeCli() {
|
|
|
600
598
|
logger.info('bridge.started', core.getRuntimeStatus());
|
|
601
599
|
const shutdown = async (signal) => {
|
|
602
600
|
logger.info('bridge.shutting_down', { signal });
|
|
601
|
+
await authNotificationAggregator.flushAll();
|
|
603
602
|
singleAuthSync?.stop();
|
|
604
603
|
singleMirror?.stop();
|
|
605
604
|
await weixinAdapter?.stop();
|
|
@@ -630,6 +629,7 @@ async function runServeCli() {
|
|
|
630
629
|
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
631
630
|
}
|
|
632
631
|
catch (error) {
|
|
632
|
+
await authNotificationAggregator.flushAll().catch(() => { });
|
|
633
633
|
activeAuthSync?.stop();
|
|
634
634
|
activeAuthMirror?.stop();
|
|
635
635
|
await weixinAdapter?.stop().catch(() => { });
|
|
@@ -641,7 +641,20 @@ async function runServeCli() {
|
|
|
641
641
|
throw error;
|
|
642
642
|
}
|
|
643
643
|
}
|
|
644
|
-
function
|
|
644
|
+
function createAuthMirrorNotifier(store, botId, bot, aggregator) {
|
|
645
|
+
return async (event) => {
|
|
646
|
+
const privateScope = store.getTelegramPrivateScope(botId);
|
|
647
|
+
if (!privateScope)
|
|
648
|
+
return;
|
|
649
|
+
const locale = store.getChatSettings(privateScope.scopeId)?.locale ?? 'en';
|
|
650
|
+
aggregator.enqueueMirror({
|
|
651
|
+
key: authNotificationDestinationKey(botId, privateScope.chatId),
|
|
652
|
+
locale,
|
|
653
|
+
sendMessage: (text) => bot.sendMessage(privateScope.chatId, text),
|
|
654
|
+
}, event);
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
function createAuthSyncNotifier(store, bot, aggregator) {
|
|
645
658
|
return async (event) => {
|
|
646
659
|
if (!bot.identity)
|
|
647
660
|
return;
|
|
@@ -649,9 +662,20 @@ function createAuthSyncNotifier(store, bot) {
|
|
|
649
662
|
if (!privateScope)
|
|
650
663
|
return;
|
|
651
664
|
const locale = store.getChatSettings(privateScope.scopeId)?.locale ?? 'en';
|
|
665
|
+
const destination = {
|
|
666
|
+
key: authNotificationDestinationKey(bot.identity, privateScope.chatId),
|
|
667
|
+
locale,
|
|
668
|
+
sendMessage: (text) => bot.sendMessage(privateScope.chatId, text),
|
|
669
|
+
};
|
|
670
|
+
if (aggregator.enqueueAuthSync(destination, event)) {
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
652
673
|
await bot.sendMessage(privateScope.chatId, formatAuthSyncNotification(locale, event));
|
|
653
674
|
};
|
|
654
675
|
}
|
|
676
|
+
function authNotificationDestinationKey(botId, chatId) {
|
|
677
|
+
return `${botId}:${chatId}`;
|
|
678
|
+
}
|
|
655
679
|
function formatAuthSyncNotification(locale, event) {
|
|
656
680
|
const peers = 'peers' in event ? formatPeerList(event.peers, locale) : '';
|
|
657
681
|
if (locale === 'zh') {
|
|
@@ -166,7 +166,7 @@ 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 compatible ChatGPT user/email identity, 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.
|
|
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. Refresh/send/import bursts are grouped into short summaries so one candidate update does not produce separate start, receive, mirror-write, and completion messages. Recovery and manual-intervention notices remain explicit. Notifications never include auth contents, tokens, or encrypted bundle payloads.
|
|
170
170
|
|
|
171
171
|
Starting in 0.5.2, `/auth sync status` separates sync-system `Last error` from per-auth `Candidate failures`. For example, a remote candidate that returns `token_invalidated` or has an expired access token is recorded under that candidate name only; current `auth.json` health is still determined by validating the current auth usage. `local candidate is already newer or equal` is a normal skip, not an error.
|
|
172
172
|
|
package/docs/user-manual.md
CHANGED
|
@@ -421,11 +421,11 @@ When an auth candidate has already failed while in use and FoxClaw cannot recove
|
|
|
421
421
|
|
|
422
422
|
`/auth refresh all` is a command-only maintenance action because ChatGPT refresh tokens are rotated. It is allowed only when every Telegram runtime, the Weixin runtime, approvals, inputs, logins, and auth mirroring are idle. The command first shows a risk confirmation: if OpenAI/Codex consumes an old refresh token but the new token cannot be saved because of network, process, or disk failure, that candidate may require device login or phone verification again. After confirmation, FoxClaw visits every ChatGPT candidate, asks Codex to force-refresh tokens with `account/read refreshToken=true`, verifies the result through the usage endpoint, mirrors successful candidates, restores the original current auth, and shows a summary.
|
|
423
423
|
|
|
424
|
-
OpenAI does not publish a fixed ChatGPT refresh-token lifetime or an old-token replay grace period. Codex refreshes automatically when an access token approaches expiry; when it cannot parse the access-token `exp`, current Codex uses a `last_refresh` fallback of about 8 days. The panel labels candidates without a refresh record in that interval as `not recently refreshed`. FoxClaw also checks once per hour in the background: if an enabled ChatGPT candidate has a `last_refresh` older than 9 days, FoxClaw proactively refreshes that batch only when every runtime is idle, no approvals/inputs/logins/auth mirror writes are active, and the node holds the cross-node refresh lease. The private bot chat
|
|
424
|
+
OpenAI does not publish a fixed ChatGPT refresh-token lifetime or an old-token replay grace period. Codex refreshes automatically when an access token approaches expiry; when it cannot parse the access-token `exp`, current Codex uses a `last_refresh` fallback of about 8 days. The panel labels candidates without a refresh record in that interval as `not recently refreshed`. FoxClaw also checks once per hour in the background: if an enabled ChatGPT candidate has a `last_refresh` older than 9 days, FoxClaw proactively refreshes that batch only when every runtime is idle, no approvals/inputs/logins/auth mirror writes are active, and the node holds the cross-node refresh lease. The private bot chat shows one proactive-refresh status message and edits it to the final result. Newer candidates continue through same-node mirroring and cross-node sync, and bursty mirror/cross-node refresh notices are grouped into short summary messages.
|
|
425
425
|
|
|
426
426
|
### 6.4 Cross-Node Auth Sync
|
|
427
427
|
|
|
428
|
-
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;
|
|
428
|
+
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; refresh/send/import bursts are grouped into summaries, while recovery and manual-intervention notices remain explicit. 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.
|
|
429
429
|
|
|
430
430
|
For the full design, safety boundaries, `.env` examples, and troubleshooting, read the [Cross-Node Auth Sync Setup Guide](./cross-node-auth-sync.md).
|
|
431
431
|
|
|
@@ -166,7 +166,7 @@ auth sync 测试完成:已发送 1,收到回应 1。
|
|
|
166
166
|
|
|
167
167
|
注意:`/auth sync push all` 的“已发送”只代表本节点把加密包发给 Telegram 成功,不代表对端已经写盘。对端只有在全局空闲、usage 验证通过、同名候选 account id 一致且 ChatGPT 用户/邮箱身份兼容,并且远端 `last_refresh` 比本地更新时才会覆盖文件。如果本地已经是相同或更新版本,文件不会变化,`最近导入` 也可能保持为空。
|
|
168
168
|
|
|
169
|
-
启用跨节点同步后,联系人 bot 的私聊会收到节点级通知:本机 auth 更新并开始发往哪些 peer、收到远端包后是排队还是立即验证、导入成功/跳过/失败原因、auth 恢复时正在查询哪些 peer、peer 回应了什么,以及所有 peer
|
|
169
|
+
启用跨节点同步后,联系人 bot 的私聊会收到节点级通知:本机 auth 更新并开始发往哪些 peer、收到远端包后是排队还是立即验证、导入成功/跳过/失败原因、auth 恢复时正在查询哪些 peer、peer 回应了什么,以及所有 peer 都无法提供可用副本时的人工介入提示。刷新、发送、导入密集发生时会合并成简短汇总,避免一个候选更新拆成开始、收到、镜像写入和完成多条消息;恢复和人工介入提示仍会明确发出。通知不会包含 auth 内容、token 或同步密文。
|
|
170
170
|
|
|
171
171
|
从 0.5.2 起,`/auth sync status` 会把同步系统级 `最近错误` 和单个 auth 的 `候选失败` 分开显示。比如某个远端候选返回 `token_invalidated` 或 access token 过期时,只会记录到该候选名下面;当前 `auth.json` 是否健康仍以当前 auth 的 usage 验证为准。`local candidate is already newer or equal` 属于正常跳过,不会记为错误。
|
|
172
172
|
|
package/docs/zh/user-manual.md
CHANGED
|
@@ -421,11 +421,11 @@ Candidates: 2
|
|
|
421
421
|
|
|
422
422
|
`/auth refresh all` 是仅命令入口的维护操作,因为 ChatGPT refresh token 会被轮换。只有所有 Telegram runtime、微信 runtime、审批、待输入、登录流程和 auth 镜像写入都空闲时才允许执行。命令会先显示风险确认:如果 OpenAI/Codex 已经消费旧 refresh token,但因为网络、进程或磁盘故障导致新 token 没能成功保存,该候选可能需要重新设备登录,甚至重新手机号验证。确认后,它会逐个访问 ChatGPT 候选,让 Codex 通过 `account/read refreshToken=true` 强制刷新 token,再用 usage 接口验证,成功后镜像到其他 bot home,最后恢复原本的当前 auth 并显示摘要。
|
|
423
423
|
|
|
424
|
-
OpenAI 没有公开 ChatGPT refresh token 的固定有效期或旧 token 重放宽限期。Codex 会在 access token 临近到期时自动刷新;如果 access token 里无法解析 `exp`,Codex 当前使用 `last_refresh` 超过约 8 天作为兜底刷新条件。面板把超过 8 天没有刷新记录的候选标为“长期未刷新”。FoxClaw 还会在后台每小时检查一次:已启用的 ChatGPT 候选如果 `last_refresh` 超过 9 天,会在所有 runtime 空闲、没有审批/待输入/登录/auth
|
|
424
|
+
OpenAI 没有公开 ChatGPT refresh token 的固定有效期或旧 token 重放宽限期。Codex 会在 access token 临近到期时自动刷新;如果 access token 里无法解析 `exp`,Codex 当前使用 `last_refresh` 超过约 8 天作为兜底刷新条件。面板把超过 8 天没有刷新记录的候选标为“长期未刷新”。FoxClaw 还会在后台每小时检查一次:已启用的 ChatGPT 候选如果 `last_refresh` 超过 9 天,会在所有 runtime 空闲、没有审批/待输入/登录/auth 镜像写入,并且拿到跨节点刷新锁后,主动刷新这一批候选。私聊里会显示一条主动刷新状态消息,并在结束时编辑成最终结果;较新的候选会继续镜像和跨节点同步,成批出现的镜像/跨节点刷新通知会合并成简短汇总。
|
|
425
425
|
|
|
426
426
|
### 6.4 跨节点 auth 同步
|
|
427
427
|
|
|
428
|
-
跨节点 auth 同步默认关闭。它适合你在多台自己控制的机器上使用同一组合法 ChatGPT 账号候选,并希望某台机器上 Codex 自动刷新出的新 token 能同步到其他机器。v1 使用 Telegram Bot-to-Bot 私聊传输加密文件,不需要公网 IP 或 FRP。推荐每台机器选择一个联系人 bot;同一节点内其他 bot 继续使用本机 auth 镜像。多 bot 模式下,默认联系人是 `TG_BOT_TOKENS` 的第一个 token。联系人 bot
|
|
428
|
+
跨节点 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 或请求。
|
|
429
429
|
|
|
430
430
|
完整设计、安全边界、`.env` 示例和排查步骤见 [跨节点 auth 同步配置指南](./cross-node-auth-sync.md)。
|
|
431
431
|
|