@foxden-app/foxclaw 0.5.5 → 0.5.6
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 +14 -0
- package/dist/auth/cross_node_sync.js +8 -2
- package/dist/controller/controller.d.ts +8 -0
- package/dist/controller/controller.js +93 -4
- package/dist/i18n.d.ts +6 -0
- package/dist/i18n.js +6 -0
- package/dist/main.js +42 -6
- package/docs/cross-node-auth-sync.md +4 -4
- package/docs/user-manual.md +3 -3
- package/docs/zh/cross-node-auth-sync.md +4 -4
- package/docs/zh/user-manual.md +3 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
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.6 - 2026-06-04
|
|
6
|
+
|
|
7
|
+
### 中文
|
|
8
|
+
- 新增后台主动 auth 刷新:已启用的 ChatGPT 候选 `last_refresh` 超过 9 天时,FoxClaw 每小时检查并在全局空闲后只刷新这批候选。
|
|
9
|
+
- 主动刷新会先申请跨节点刷新锁;没有启用跨节点同步时使用同进程本地锁,避免同节点多个 bot 并发轮换 refresh token。
|
|
10
|
+
- 跨节点刷新锁持有窗口延长到 10 分钟,刷新中会让 auth sync 处于非空闲状态,重复申请会被拒绝;完成后会私聊报告刷新、跳过和失败数量。
|
|
11
|
+
- 中文/英文用户手册和跨节点同步文档补充 9 天主动刷新策略,明确 `/auth refresh all confirm` 仍是人工维护命令。
|
|
12
|
+
|
|
13
|
+
### English
|
|
14
|
+
- Added background proactive auth refresh: every hour FoxClaw checks enabled ChatGPT candidates whose `last_refresh` is older than 9 days and refreshes only that due batch after the node is globally idle.
|
|
15
|
+
- Proactive refresh first requests the cross-node refresh lease; when cross-node sync is disabled, an in-process local lease prevents same-node bot runtimes from rotating refresh tokens concurrently.
|
|
16
|
+
- Extended the cross-node refresh lease window to 10 minutes, marks auth sync non-idle while a lease is active, rejects duplicate lease requests, and reports refreshed/skipped/failed counts in private chat.
|
|
17
|
+
- Updated the Chinese and English user manuals plus cross-node sync docs with the 9-day proactive policy, while keeping `/auth refresh all confirm` as a manual maintenance command.
|
|
18
|
+
|
|
5
19
|
## 0.5.5 - 2026-06-04
|
|
6
20
|
|
|
7
21
|
### 中文
|
|
@@ -8,7 +8,7 @@ const NONCE_RETENTION_MS = 7 * 24 * 60 * 60_000;
|
|
|
8
8
|
const PULL_TIMEOUT_MS = 12_000;
|
|
9
9
|
const TEST_TIMEOUT_MS = 8_000;
|
|
10
10
|
const LEASE_TIMEOUT_MS = 8_000;
|
|
11
|
-
const LEASE_TTL_MS = 60_000;
|
|
11
|
+
const LEASE_TTL_MS = 10 * 60_000;
|
|
12
12
|
const REMOTE_ACCESS_TOKEN_MIN_TTL_MS = 60_000;
|
|
13
13
|
export class CrossNodeAuthSync {
|
|
14
14
|
config;
|
|
@@ -116,7 +116,9 @@ export class CrossNodeAuthSync {
|
|
|
116
116
|
&& !this.importProcessorActive
|
|
117
117
|
&& this.pendingPulls.size === 0
|
|
118
118
|
&& this.pendingLeases.size === 0
|
|
119
|
-
&& this.pendingTests.size === 0
|
|
119
|
+
&& this.pendingTests.size === 0
|
|
120
|
+
&& this.activeLocalLease === null
|
|
121
|
+
&& this.activeRemoteLease === null;
|
|
120
122
|
}
|
|
121
123
|
async publishCandidate(candidateName) {
|
|
122
124
|
if (!this.isReady())
|
|
@@ -263,6 +265,10 @@ export class CrossNodeAuthSync {
|
|
|
263
265
|
return result;
|
|
264
266
|
}
|
|
265
267
|
async acquireRefreshLease(reason) {
|
|
268
|
+
this.expireLeases();
|
|
269
|
+
if (this.activeLocalLease || this.activeRemoteLease) {
|
|
270
|
+
return { ok: false, leaseId: null, reason: 'another refresh lease is active' };
|
|
271
|
+
}
|
|
266
272
|
if (!this.isReady() || this.peers.length === 0) {
|
|
267
273
|
const leaseId = crypto.randomUUID();
|
|
268
274
|
this.activeLocalLease = { leaseId, expiresAt: Date.now() + LEASE_TTL_MS };
|
|
@@ -80,6 +80,8 @@ export declare class BridgeSessionCore {
|
|
|
80
80
|
private approvalTimers;
|
|
81
81
|
private submittedUserInputTimers;
|
|
82
82
|
private selfUpdatePollTimer;
|
|
83
|
+
private proactiveAuthRefreshTimer;
|
|
84
|
+
private proactiveAuthRefreshInProgress;
|
|
83
85
|
private attachedThreads;
|
|
84
86
|
private botUsername;
|
|
85
87
|
private lastError;
|
|
@@ -262,6 +264,11 @@ export declare class BridgeSessionCore {
|
|
|
262
264
|
private pollSelfUpdateStatus;
|
|
263
265
|
private formatSelfUpdateResult;
|
|
264
266
|
private formatCodexUpdateResult;
|
|
267
|
+
private scheduleProactiveAuthRefresh;
|
|
268
|
+
private clearProactiveAuthRefreshTimer;
|
|
269
|
+
private runProactiveAuthRefresh;
|
|
270
|
+
private proactiveAuthRefreshLocale;
|
|
271
|
+
private notifyProactiveAuthRefresh;
|
|
265
272
|
private handleAuthCommand;
|
|
266
273
|
private handleAuthSyncCommand;
|
|
267
274
|
private handleAuthRefreshAllCommand;
|
|
@@ -318,6 +325,7 @@ export declare class BridgeSessionCore {
|
|
|
318
325
|
private recoverCodexAuthCandidate;
|
|
319
326
|
private syncCodexAuthCandidate;
|
|
320
327
|
private refreshAllCodexAuthCandidates;
|
|
328
|
+
private refreshCodexAuthCandidates;
|
|
321
329
|
private buildNativeCollaborationMode;
|
|
322
330
|
private buildCodexUsageStatusLines;
|
|
323
331
|
private buildCodexLocalUsageStatusLines;
|
|
@@ -30,6 +30,9 @@ const CODEX_AUTH_QUOTA_SNAPSHOT_FILENAME = 'codex-auth-quota.json';
|
|
|
30
30
|
const CODEX_AUTH_LIST_PAGE_SIZE = 8;
|
|
31
31
|
const CODEX_AUTH_LOW_QUOTA_PERCENT = 10;
|
|
32
32
|
const CODEX_AUTH_STALE_CREDENTIAL_DAYS = 8;
|
|
33
|
+
const CODEX_AUTH_PROACTIVE_REFRESH_DAYS = 9;
|
|
34
|
+
const CODEX_AUTH_PROACTIVE_REFRESH_INTERVAL_MS = 60 * 60_000;
|
|
35
|
+
const CODEX_AUTH_PROACTIVE_REFRESH_INITIAL_DELAY_MS = 5 * 60_000;
|
|
33
36
|
const USER_INPUT_SUBMITTED_NOTICE_MS = 90_000;
|
|
34
37
|
const SELF_UPDATE_STATUS_POLL_MS = 1000;
|
|
35
38
|
const PLAN_IMPLEMENTATION_CODING_MESSAGE = 'Implement the plan.';
|
|
@@ -132,6 +135,8 @@ export class BridgeSessionCore {
|
|
|
132
135
|
approvalTimers = new Map();
|
|
133
136
|
submittedUserInputTimers = new Map();
|
|
134
137
|
selfUpdatePollTimer = null;
|
|
138
|
+
proactiveAuthRefreshTimer = null;
|
|
139
|
+
proactiveAuthRefreshInProgress = false;
|
|
135
140
|
attachedThreads = new Set();
|
|
136
141
|
botUsername = null;
|
|
137
142
|
lastError = null;
|
|
@@ -206,6 +211,7 @@ export class BridgeSessionCore {
|
|
|
206
211
|
});
|
|
207
212
|
this.updateStatus();
|
|
208
213
|
this.scheduleSelfUpdateStatusPoll(0);
|
|
214
|
+
this.scheduleProactiveAuthRefresh(CODEX_AUTH_PROACTIVE_REFRESH_INITIAL_DELAY_MS);
|
|
209
215
|
}
|
|
210
216
|
/** Begin Telegram Bot API long-polling after handlers and Codex are ready. */
|
|
211
217
|
async startTelegramPolling() {
|
|
@@ -249,6 +255,7 @@ export class BridgeSessionCore {
|
|
|
249
255
|
}
|
|
250
256
|
this.submittedUserInputTimers.clear();
|
|
251
257
|
this.clearSelfUpdateStatusPoll();
|
|
258
|
+
this.clearProactiveAuthRefreshTimer();
|
|
252
259
|
await this.app.stop({ terminateServer: false });
|
|
253
260
|
this.updateStatus();
|
|
254
261
|
}
|
|
@@ -3960,6 +3967,82 @@ export class BridgeSessionCore {
|
|
|
3960
3967
|
}
|
|
3961
3968
|
return status.codexUpdate ?? null;
|
|
3962
3969
|
}
|
|
3970
|
+
scheduleProactiveAuthRefresh(delayMs = CODEX_AUTH_PROACTIVE_REFRESH_INTERVAL_MS) {
|
|
3971
|
+
if (this.proactiveAuthRefreshTimer) {
|
|
3972
|
+
return;
|
|
3973
|
+
}
|
|
3974
|
+
this.proactiveAuthRefreshTimer = setTimeout(() => {
|
|
3975
|
+
this.proactiveAuthRefreshTimer = null;
|
|
3976
|
+
void this.runProactiveAuthRefresh().catch((error) => {
|
|
3977
|
+
this.logger.warn('codex.auth_proactive_refresh_failed', { error: toErrorMeta(error) });
|
|
3978
|
+
}).finally(() => {
|
|
3979
|
+
this.scheduleProactiveAuthRefresh();
|
|
3980
|
+
});
|
|
3981
|
+
}, delayMs);
|
|
3982
|
+
this.proactiveAuthRefreshTimer.unref();
|
|
3983
|
+
}
|
|
3984
|
+
clearProactiveAuthRefreshTimer() {
|
|
3985
|
+
if (!this.proactiveAuthRefreshTimer) {
|
|
3986
|
+
return;
|
|
3987
|
+
}
|
|
3988
|
+
clearTimeout(this.proactiveAuthRefreshTimer);
|
|
3989
|
+
this.proactiveAuthRefreshTimer = null;
|
|
3990
|
+
}
|
|
3991
|
+
async runProactiveAuthRefresh() {
|
|
3992
|
+
if (this.proactiveAuthRefreshInProgress || !this.canRunGlobalAuthRefresh()) {
|
|
3993
|
+
return;
|
|
3994
|
+
}
|
|
3995
|
+
const state = await this.listCodexAuthState();
|
|
3996
|
+
const dueCandidates = state.candidates.filter(candidate => !candidate.disabled
|
|
3997
|
+
&& candidate.credentialKind === 'chatgpt'
|
|
3998
|
+
&& candidate.credentialLastRefreshMs !== null
|
|
3999
|
+
&& candidate.credentialLastRefreshMs <= Date.now() - CODEX_AUTH_PROACTIVE_REFRESH_DAYS * 24 * 60 * 60_000);
|
|
4000
|
+
if (dueCandidates.length === 0) {
|
|
4001
|
+
return;
|
|
4002
|
+
}
|
|
4003
|
+
this.proactiveAuthRefreshInProgress = true;
|
|
4004
|
+
const locale = this.proactiveAuthRefreshLocale();
|
|
4005
|
+
await this.notifyProactiveAuthRefresh(locale, t(locale, 'auth_proactive_refresh_starting', {
|
|
4006
|
+
value: dueCandidates.map(candidate => candidate.name).join(', '),
|
|
4007
|
+
}));
|
|
4008
|
+
let lease;
|
|
4009
|
+
try {
|
|
4010
|
+
lease = await this.coordinator?.acquireAuthRefreshLease?.(`proactive auth refresh: ${dueCandidates.map(candidate => candidate.name).join(', ')}`);
|
|
4011
|
+
if (lease && !lease.ok) {
|
|
4012
|
+
this.logger.warn('codex.auth_proactive_refresh_lease_failed', { reason: lease.reason });
|
|
4013
|
+
await this.notifyProactiveAuthRefresh(locale, t(locale, 'auth_proactive_refresh_lease_failed', {
|
|
4014
|
+
error: lease.reason ?? t(locale, 'unknown'),
|
|
4015
|
+
}));
|
|
4016
|
+
return;
|
|
4017
|
+
}
|
|
4018
|
+
const result = await this.refreshCodexAuthCandidates(new Set(dueCandidates.map(candidate => candidate.name)));
|
|
4019
|
+
await this.notifyProactiveAuthRefresh(locale, formatAuthRefreshAllResult(locale, result, 'proactive'));
|
|
4020
|
+
}
|
|
4021
|
+
finally {
|
|
4022
|
+
await this.coordinator?.releaseAuthRefreshLease?.(lease?.leaseId ?? null);
|
|
4023
|
+
this.proactiveAuthRefreshInProgress = false;
|
|
4024
|
+
}
|
|
4025
|
+
}
|
|
4026
|
+
proactiveAuthRefreshLocale() {
|
|
4027
|
+
const identity = this.bot.identity;
|
|
4028
|
+
if (!identity)
|
|
4029
|
+
return 'en';
|
|
4030
|
+
const privateScope = this.store.getTelegramPrivateScope(identity);
|
|
4031
|
+
if (!privateScope)
|
|
4032
|
+
return 'en';
|
|
4033
|
+
return this.localeForChat(privateScope.scopeId);
|
|
4034
|
+
}
|
|
4035
|
+
async notifyProactiveAuthRefresh(locale, message) {
|
|
4036
|
+
const identity = this.bot.identity;
|
|
4037
|
+
if (!identity)
|
|
4038
|
+
return;
|
|
4039
|
+
const privateScope = this.store.getTelegramPrivateScope(identity);
|
|
4040
|
+
if (!privateScope)
|
|
4041
|
+
return;
|
|
4042
|
+
await this.bot.sendMessage(privateScope.chatId, message).catch((error) => {
|
|
4043
|
+
this.logger.warn('codex.auth_proactive_refresh_notify_failed', { error: toErrorMeta(error) });
|
|
4044
|
+
});
|
|
4045
|
+
}
|
|
3963
4046
|
async handleAuthCommand(scopeId, locale, args) {
|
|
3964
4047
|
const action = args[0]?.toLowerCase() ?? 'list';
|
|
3965
4048
|
if (action === 'sync') {
|
|
@@ -4902,12 +4985,18 @@ export class BridgeSessionCore {
|
|
|
4902
4985
|
}
|
|
4903
4986
|
}
|
|
4904
4987
|
async refreshAllCodexAuthCandidates() {
|
|
4988
|
+
return this.refreshCodexAuthCandidates();
|
|
4989
|
+
}
|
|
4990
|
+
async refreshCodexAuthCandidates(candidateNames = null) {
|
|
4905
4991
|
if (this.authRefreshAllInProgress) {
|
|
4906
4992
|
throw new UserFacingError('Auth refresh all is already running.');
|
|
4907
4993
|
}
|
|
4908
4994
|
const initialState = await this.listCodexAuthState();
|
|
4909
4995
|
const result = { refreshed: [], skipped: [], failed: [] };
|
|
4910
|
-
|
|
4996
|
+
const candidates = candidateNames
|
|
4997
|
+
? initialState.candidates.filter(candidate => candidateNames.has(candidate.name))
|
|
4998
|
+
: initialState.candidates;
|
|
4999
|
+
if (candidates.length === 0) {
|
|
4911
5000
|
return result;
|
|
4912
5001
|
}
|
|
4913
5002
|
const authStat = await fs.lstat(initialState.authPath).catch(() => null);
|
|
@@ -4917,7 +5006,7 @@ export class BridgeSessionCore {
|
|
|
4917
5006
|
let changedAuthTarget = false;
|
|
4918
5007
|
this.authRefreshAllInProgress = true;
|
|
4919
5008
|
try {
|
|
4920
|
-
for (const candidate of
|
|
5009
|
+
for (const candidate of candidates) {
|
|
4921
5010
|
const before = await readChatGptAuthMetadata(candidate.path);
|
|
4922
5011
|
if (!before) {
|
|
4923
5012
|
result.skipped.push(candidate.name);
|
|
@@ -8104,8 +8193,8 @@ function authRefreshAllConfirmKeyboard(locale, record) {
|
|
|
8104
8193
|
[{ text: t(locale, 'button_cancel'), callback_data: `auth:${record.localId}:refresh_all_cancel` }],
|
|
8105
8194
|
];
|
|
8106
8195
|
}
|
|
8107
|
-
function formatAuthRefreshAllResult(locale, result) {
|
|
8108
|
-
const lines = [t(locale, 'auth_refresh_all_done', {
|
|
8196
|
+
function formatAuthRefreshAllResult(locale, result, mode = 'manual') {
|
|
8197
|
+
const lines = [t(locale, mode === 'proactive' ? 'auth_proactive_refresh_done' : 'auth_refresh_all_done', {
|
|
8109
8198
|
refreshed: String(result.refreshed.length),
|
|
8110
8199
|
skipped: String(result.skipped.length),
|
|
8111
8200
|
failed: String(result.failed.length),
|
package/dist/i18n.d.ts
CHANGED
|
@@ -186,6 +186,9 @@ declare const MESSAGES: {
|
|
|
186
186
|
readonly auth_refresh_all_blocked_active: "Cannot refresh all auth candidates while any runtime, approval, input, login, or auth mirror write is active. Wait or use /interrupt first.";
|
|
187
187
|
readonly auth_refresh_all_lease_failed: "Cross-node refresh lock was not granted: {error}";
|
|
188
188
|
readonly auth_refresh_all_done: "Auth refresh all complete: {refreshed} refreshed, {skipped} skipped, {failed} failed.";
|
|
189
|
+
readonly auth_proactive_refresh_starting: "Proactive auth refresh started for candidates older than 9 days: {value}";
|
|
190
|
+
readonly auth_proactive_refresh_lease_failed: "Proactive auth refresh skipped because the cross-node refresh lock was not granted: {error}";
|
|
191
|
+
readonly auth_proactive_refresh_done: "Proactive auth refresh complete: {refreshed} refreshed, {skipped} skipped, {failed} failed.";
|
|
189
192
|
readonly auth_refresh_all_refreshed: "Refreshed: {value}";
|
|
190
193
|
readonly auth_refresh_all_skipped: "Skipped non-ChatGPT/invalid candidates: {value}";
|
|
191
194
|
readonly auth_refresh_all_failed: "Failed: {value}";
|
|
@@ -817,6 +820,9 @@ declare const MESSAGES: {
|
|
|
817
820
|
readonly auth_refresh_all_blocked_active: "当前有任一 runtime、审批、待输入、登录或 auth 镜像写入在进行中,不能刷新全部 auth。请先等待,或使用 /interrupt。";
|
|
818
821
|
readonly auth_refresh_all_lease_failed: "跨节点刷新锁未授予:{error}";
|
|
819
822
|
readonly auth_refresh_all_done: "全部 auth 刷新完成:已刷新 {refreshed},已跳过 {skipped},失败 {failed}。";
|
|
823
|
+
readonly auth_proactive_refresh_starting: "开始主动刷新超过 9 天未更新的 auth 候选:{value}";
|
|
824
|
+
readonly auth_proactive_refresh_lease_failed: "主动 auth 刷新已跳过:跨节点刷新锁未授予:{error}";
|
|
825
|
+
readonly auth_proactive_refresh_done: "主动 auth 刷新完成:已刷新 {refreshed},已跳过 {skipped},失败 {failed}。";
|
|
820
826
|
readonly auth_refresh_all_refreshed: "已刷新:{value}";
|
|
821
827
|
readonly auth_refresh_all_skipped: "已跳过非 ChatGPT/无效候选:{value}";
|
|
822
828
|
readonly auth_refresh_all_failed: "失败:{value}";
|
package/dist/i18n.js
CHANGED
|
@@ -184,6 +184,9 @@ const MESSAGES = {
|
|
|
184
184
|
auth_refresh_all_blocked_active: 'Cannot refresh all auth candidates while any runtime, approval, input, login, or auth mirror write is active. Wait or use /interrupt first.',
|
|
185
185
|
auth_refresh_all_lease_failed: 'Cross-node refresh lock was not granted: {error}',
|
|
186
186
|
auth_refresh_all_done: 'Auth refresh all complete: {refreshed} refreshed, {skipped} skipped, {failed} failed.',
|
|
187
|
+
auth_proactive_refresh_starting: 'Proactive auth refresh started for candidates older than 9 days: {value}',
|
|
188
|
+
auth_proactive_refresh_lease_failed: 'Proactive auth refresh skipped because the cross-node refresh lock was not granted: {error}',
|
|
189
|
+
auth_proactive_refresh_done: 'Proactive auth refresh complete: {refreshed} refreshed, {skipped} skipped, {failed} failed.',
|
|
187
190
|
auth_refresh_all_refreshed: 'Refreshed: {value}',
|
|
188
191
|
auth_refresh_all_skipped: 'Skipped non-ChatGPT/invalid candidates: {value}',
|
|
189
192
|
auth_refresh_all_failed: 'Failed: {value}',
|
|
@@ -815,6 +818,9 @@ const MESSAGES = {
|
|
|
815
818
|
auth_refresh_all_blocked_active: '当前有任一 runtime、审批、待输入、登录或 auth 镜像写入在进行中,不能刷新全部 auth。请先等待,或使用 /interrupt。',
|
|
816
819
|
auth_refresh_all_lease_failed: '跨节点刷新锁未授予:{error}',
|
|
817
820
|
auth_refresh_all_done: '全部 auth 刷新完成:已刷新 {refreshed},已跳过 {skipped},失败 {failed}。',
|
|
821
|
+
auth_proactive_refresh_starting: '开始主动刷新超过 9 天未更新的 auth 候选:{value}',
|
|
822
|
+
auth_proactive_refresh_lease_failed: '主动 auth 刷新已跳过:跨节点刷新锁未授予:{error}',
|
|
823
|
+
auth_proactive_refresh_done: '主动 auth 刷新完成:已刷新 {refreshed},已跳过 {skipped},失败 {failed}。',
|
|
818
824
|
auth_refresh_all_refreshed: '已刷新:{value}',
|
|
819
825
|
auth_refresh_all_skipped: '已跳过非 ChatGPT/无效候选:{value}',
|
|
820
826
|
auth_refresh_all_failed: '失败:{value}',
|
package/dist/main.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import crypto from 'node:crypto';
|
|
2
3
|
import fs from 'node:fs';
|
|
3
4
|
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
@@ -27,6 +28,39 @@ const PROXY_ENV_KEYS = [
|
|
|
27
28
|
'no_proxy',
|
|
28
29
|
];
|
|
29
30
|
const STANDARD_NODE_PROXY_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy'];
|
|
31
|
+
const LOCAL_AUTH_REFRESH_LEASE_TTL_MS = 10 * 60_000;
|
|
32
|
+
function createLocalAuthRefreshLease() {
|
|
33
|
+
let active = null;
|
|
34
|
+
const expire = () => {
|
|
35
|
+
if (active && active.expiresAt <= Date.now()) {
|
|
36
|
+
active = null;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
isIdle: () => {
|
|
41
|
+
expire();
|
|
42
|
+
return active === null;
|
|
43
|
+
},
|
|
44
|
+
acquire: async (reason) => {
|
|
45
|
+
expire();
|
|
46
|
+
if (active) {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
leaseId: null,
|
|
50
|
+
reason: `another local auth refresh lease is active: ${active.reason}`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const leaseId = crypto.randomUUID();
|
|
54
|
+
active = { leaseId, reason, expiresAt: Date.now() + LOCAL_AUTH_REFRESH_LEASE_TTL_MS };
|
|
55
|
+
return { ok: true, leaseId };
|
|
56
|
+
},
|
|
57
|
+
release: async (leaseId) => {
|
|
58
|
+
if (leaseId && active?.leaseId === leaseId) {
|
|
59
|
+
active = null;
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
30
64
|
async function main() {
|
|
31
65
|
if (isVersionCommand(command)) {
|
|
32
66
|
console.log(readPackageVersion());
|
|
@@ -239,6 +273,7 @@ async function runServeCli() {
|
|
|
239
273
|
const lastSelfUpdatePath = path.join(APP_HOME, 'runtime', 'last-self-update.json');
|
|
240
274
|
let lastSelfUpdate = readSelfUpdateStatus(lastSelfUpdatePath);
|
|
241
275
|
const runtimes = [];
|
|
276
|
+
const localAuthRefreshLease = createLocalAuthRefreshLease();
|
|
242
277
|
const writeAggregateStatus = (running = true) => {
|
|
243
278
|
const statuses = runtimes.map((runtime) => runtime.core.getRuntimeStatus());
|
|
244
279
|
const weixinStatus = activeWeixinCore?.getRuntimeStatus() ?? null;
|
|
@@ -286,7 +321,7 @@ async function runServeCli() {
|
|
|
286
321
|
&& mirror.isIdle();
|
|
287
322
|
const coordinator = {
|
|
288
323
|
canSelfUpdate: () => authSyncLocalIdle()
|
|
289
|
-
&& (
|
|
324
|
+
&& (authSync ? authSync.isIdle() : localAuthRefreshLease.isIdle()),
|
|
290
325
|
authCandidateUpdated: (runtimeId, candidateName) => mirror.syncRuntimeCandidate(runtimeId, candidateName).then(() => undefined),
|
|
291
326
|
recoverAuthCandidate: async (runtimeId, candidateName, options = {}) => {
|
|
292
327
|
const local = await mirror.recoverRuntimeCandidate(runtimeId, candidateName);
|
|
@@ -302,9 +337,9 @@ async function runServeCli() {
|
|
|
302
337
|
}) ?? false;
|
|
303
338
|
},
|
|
304
339
|
acquireAuthRefreshLease: (reason) => authSync?.acquireRefreshLease(reason)
|
|
305
|
-
??
|
|
340
|
+
?? localAuthRefreshLease.acquire(reason),
|
|
306
341
|
releaseAuthRefreshLease: (leaseId) => authSync?.releaseRefreshLease(leaseId)
|
|
307
|
-
??
|
|
342
|
+
?? localAuthRefreshLease.release(leaseId),
|
|
308
343
|
getAuthSyncStatus: () => authSync?.getStatus() ?? null,
|
|
309
344
|
authSyncPushAll: () => authSync?.pushAll() ?? Promise.resolve({ sent: 0, skipped: 0 }),
|
|
310
345
|
authSyncTest: () => authSync?.testPeers() ?? Promise.resolve({ sent: 0, replied: 0, missing: [] }),
|
|
@@ -438,11 +473,12 @@ async function runServeCli() {
|
|
|
438
473
|
let singleMirror = null;
|
|
439
474
|
let core = null;
|
|
440
475
|
const singleAuthDir = config.codexAuthDir ?? config.codexHome ?? process.env.CODEX_AUTH_DIR ?? path.join(os.homedir(), '.codex');
|
|
476
|
+
const singleLocalAuthRefreshLease = createLocalAuthRefreshLease();
|
|
441
477
|
const singleAuthSyncLocalIdle = () => Boolean(core?.isIdleForServiceUpdate())
|
|
442
478
|
&& (!singleMirror || singleMirror.isIdle());
|
|
443
479
|
const singleCoordinator = config.authSyncEnabled ? {
|
|
444
480
|
canSelfUpdate: () => singleAuthSyncLocalIdle()
|
|
445
|
-
&& (
|
|
481
|
+
&& (singleAuthSync ? singleAuthSync.isIdle() : singleLocalAuthRefreshLease.isIdle()),
|
|
446
482
|
authCandidateUpdated: (runtimeId, candidateName) => singleMirror?.syncRuntimeCandidate(runtimeId, candidateName).then(() => undefined) ?? Promise.resolve(),
|
|
447
483
|
recoverAuthCandidate: async (runtimeId, candidateName, options = {}) => {
|
|
448
484
|
const local = await singleMirror?.recoverRuntimeCandidate(runtimeId, candidateName) ?? null;
|
|
@@ -459,9 +495,9 @@ async function runServeCli() {
|
|
|
459
495
|
}) ?? false;
|
|
460
496
|
},
|
|
461
497
|
acquireAuthRefreshLease: (reason) => singleAuthSync?.acquireRefreshLease(reason)
|
|
462
|
-
??
|
|
498
|
+
?? singleLocalAuthRefreshLease.acquire(reason),
|
|
463
499
|
releaseAuthRefreshLease: (leaseId) => singleAuthSync?.releaseRefreshLease(leaseId)
|
|
464
|
-
??
|
|
500
|
+
?? singleLocalAuthRefreshLease.release(leaseId),
|
|
465
501
|
getAuthSyncStatus: () => singleAuthSync?.getStatus() ?? null,
|
|
466
502
|
authSyncPushAll: () => singleAuthSync?.pushAll() ?? Promise.resolve({ sent: 0, skipped: 0 }),
|
|
467
503
|
authSyncTest: () => singleAuthSync?.testPeers() ?? Promise.resolve({ sent: 0, replied: 0, missing: [] }),
|
|
@@ -10,13 +10,13 @@ Use it when:
|
|
|
10
10
|
|
|
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
|
-
- You want auth files to stay fresh across nodes
|
|
13
|
+
- You want auth files to stay fresh across nodes, and you allow FoxClaw to proactively refresh enabled ChatGPT candidates whose `last_refresh` is older than 9 days after it obtains the cross-node refresh lease.
|
|
14
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.
|
|
15
15
|
|
|
16
16
|
Do not use it when:
|
|
17
17
|
|
|
18
18
|
- The auth source is untrusted, account ownership is unclear, or you do not control every machine administrator.
|
|
19
|
-
- You
|
|
19
|
+
- You want to force refresh-token keepalive without a cross-node lease, while nodes are busy, or for disabled candidates.
|
|
20
20
|
- The same bot token is being polled by multiple machines at the same time. That breaks Telegram update delivery and FoxClaw's assumptions.
|
|
21
21
|
|
|
22
22
|
## Design And Safety Model
|
|
@@ -25,7 +25,7 @@ Cross-node sync combines three active paths:
|
|
|
25
25
|
|
|
26
26
|
- **Push**: after local login, Codex automatic refresh, or `/auth refresh all confirm` succeeds and passes usage validation, FoxClaw sends the newer candidate to peers.
|
|
27
27
|
- **Pull**: before auth switch or reload, FoxClaw first searches local runtimes for a newer candidate. If none exists, it asks peers for a newer same-name, same-account candidate.
|
|
28
|
-
- **Lease**: before `/auth refresh all confirm` rotates refresh tokens, FoxClaw requests a cross-node refresh lease. Any busy, denying, or non-responsive peer blocks the refresh.
|
|
28
|
+
- **Lease**: before `/auth refresh all confirm` or the background 9-day proactive refresh rotates refresh tokens, FoxClaw requests a cross-node refresh lease. Any busy, denying, or non-responsive peer blocks the refresh.
|
|
29
29
|
|
|
30
30
|
Safety boundaries:
|
|
31
31
|
|
|
@@ -200,4 +200,4 @@ With cross-node sync enabled, this command first requests a cross-node refresh l
|
|
|
200
200
|
|
|
201
201
|
**Should I periodically run `/auth refresh all confirm` as keepalive?**
|
|
202
202
|
|
|
203
|
-
|
|
203
|
+
Do not force it manually on a schedule. Codex refreshes automatically when access tokens expire, and FoxClaw now proactively refreshes enabled ChatGPT candidates whose `last_refresh` is older than 9 days after the node is globally idle and obtains the cross-node refresh lease. `/auth refresh all confirm` remains a manual maintenance command for cases where you explicitly accept refresh-token rotation risk.
|
package/docs/user-manual.md
CHANGED
|
@@ -419,7 +419,7 @@ The right-side `✅` / `⏸️` button controls whether the candidate participat
|
|
|
419
419
|
|
|
420
420
|
`/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.
|
|
421
421
|
|
|
422
|
-
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
|
|
422
|
+
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 reports the proactive refresh result, and newer candidates continue through same-node mirroring and cross-node sync.
|
|
423
423
|
|
|
424
424
|
### 6.4 Cross-Node Auth Sync
|
|
425
425
|
|
|
@@ -454,13 +454,13 @@ Safety boundaries:
|
|
|
454
454
|
- FoxClaw only accepts sync files from bots listed in `AUTH_SYNC_PEERS`; wrong key, cluster, nonce, or payload validation never writes files.
|
|
455
455
|
- Remote imports wait for global local idleness, temporarily switch to the candidate for app-server usage validation, and only then write the candidate.
|
|
456
456
|
- A same-name candidate known to belong to a different account id is never overwritten.
|
|
457
|
-
- Cross-node recovery only pulls an already-held valid peer copy
|
|
457
|
+
- Cross-node recovery only pulls an already-held valid peer copy and does not rotate refresh tokens during recovery. If no peer has a usable copy, it stops and asks you to maintain auth manually. The background 9-day proactive refresh separately requests the cross-node refresh lease and skips that cycle if the lease is not granted.
|
|
458
458
|
|
|
459
459
|
Dual-active behavior:
|
|
460
460
|
|
|
461
461
|
- Push: after local login, Codex automatic refresh, or `/auth refresh all` succeeds and passes local mirror validation, the newer candidate is encrypted and pushed to peers.
|
|
462
462
|
- Pull: before auth switch or reload, FoxClaw first searches local runtimes for a newer same-account candidate; if none is found, it asks peers for a newer same-name same-account copy.
|
|
463
|
-
- Lease: `/auth refresh all confirm`
|
|
463
|
+
- Lease: `/auth refresh all confirm` and the background 9-day proactive refresh request a cross-node refresh lease before rotating tokens. Any busy, denying, or non-responsive peer blocks the refresh.
|
|
464
464
|
|
|
465
465
|
Commands:
|
|
466
466
|
|
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
|
|
11
11
|
- 这些 ChatGPT 账号和 auth 文件都由你合法拥有和维护。
|
|
12
12
|
- 多台机器都运行 FoxClaw,并且每台机器至少有一个 Telegram bot。
|
|
13
|
-
- 你希望 auth
|
|
13
|
+
- 你希望 auth 文件在节点间自动保持较新,并允许 FoxClaw 在已启用 ChatGPT 候选 `last_refresh` 超过 9 天时,持有跨节点刷新锁后主动刷新。
|
|
14
14
|
- 默认推荐每台机器只选择一个“联系人 bot”参与跨节点同步;同一节点内其他 bot 继续使用原本的本机 auth 镜像。
|
|
15
15
|
|
|
16
16
|
不适合:
|
|
17
17
|
|
|
18
18
|
- 同步来源不可信、账号来源不合法,或你无法确认每台机器的管理员。
|
|
19
|
-
-
|
|
19
|
+
- 希望在没有跨节点锁、节点忙碌或候选被禁用时仍强制做 refresh token 保活。
|
|
20
20
|
- 同一个 bot token 被多台机器同时 polling;这会破坏 Telegram update 分发和 FoxClaw 的运行假设。
|
|
21
21
|
|
|
22
22
|
## 设计与安全模型
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
- **Push**:本节点登录、Codex 自动刷新或 `/auth refresh all confirm` 成功并通过本机 usage 验证后,把较新的候选加密发送给 peer。
|
|
27
27
|
- **Pull**:本节点切换或重载 auth 前,如果本机其他 runtime 没有更新副本,会向 peer 请求同名、同账号的较新候选。
|
|
28
|
-
- **Lease**:执行会旋转 refresh token 的 `/auth refresh all confirm`
|
|
28
|
+
- **Lease**:执行会旋转 refresh token 的 `/auth refresh all confirm` 或后台 9 天主动刷新前,先向 peer 申请跨节点刷新锁;任一 peer 忙碌、拒绝或无响应都会阻止刷新。
|
|
29
29
|
|
|
30
30
|
安全边界:
|
|
31
31
|
|
|
@@ -200,4 +200,4 @@ auth sync 测试完成:已发送 1,收到回应 1。
|
|
|
200
200
|
|
|
201
201
|
**要不要定期 `/auth refresh all confirm` 保活**
|
|
202
202
|
|
|
203
|
-
|
|
203
|
+
不要手动定期强刷。Codex 会按 access token 到期自动刷新;FoxClaw 也会在已启用 ChatGPT 候选 `last_refresh` 超过 9 天时,等全局空闲并拿到跨节点刷新锁后主动刷新。`/auth refresh all confirm` 仍然是人工维护命令,用于你明确接受 refresh token 轮换风险的场景。
|
package/docs/zh/user-manual.md
CHANGED
|
@@ -419,7 +419,7 @@ Candidates: 2
|
|
|
419
419
|
|
|
420
420
|
`/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 并显示摘要。
|
|
421
421
|
|
|
422
|
-
OpenAI 没有公开 ChatGPT refresh token 的固定有效期或旧 token 重放宽限期。Codex 会在 access token 临近到期时自动刷新;如果 access token 里无法解析 `exp`,Codex 当前使用 `last_refresh` 超过约 8 天作为兜底刷新条件。面板把超过 8
|
|
422
|
+
OpenAI 没有公开 ChatGPT refresh token 的固定有效期或旧 token 重放宽限期。Codex 会在 access token 临近到期时自动刷新;如果 access token 里无法解析 `exp`,Codex 当前使用 `last_refresh` 超过约 8 天作为兜底刷新条件。面板把超过 8 天没有刷新记录的候选标为“长期未刷新”。FoxClaw 还会在后台每小时检查一次:已启用的 ChatGPT 候选如果 `last_refresh` 超过 9 天,会在所有 runtime 空闲、没有审批/待输入/登录/auth 镜像写入,并且拿到跨节点刷新锁后,主动刷新这一批候选。主动刷新完成后会私聊通知,并把较新的候选继续镜像和跨节点同步。
|
|
423
423
|
|
|
424
424
|
### 6.4 跨节点 auth 同步
|
|
425
425
|
|
|
@@ -454,13 +454,13 @@ AUTH_SYNC_NODE_ID=workstation-a
|
|
|
454
454
|
- 只接收 `AUTH_SYNC_PEERS` 中 peer bot 发来的同步文件;密钥、cluster、nonce 或 payload 校验失败时不会写盘。
|
|
455
455
|
- 远端导入必须等本机全局空闲,再临时切换到待验证 auth、重启 app-server、读取 usage 验证成功后才写入候选。
|
|
456
456
|
- 同名候选如果已知属于不同 account id,永远拒绝覆盖。
|
|
457
|
-
- 跨节点恢复只拉取 peer
|
|
457
|
+
- 跨节点恢复只拉取 peer 已持有的有效副本,不会在恢复过程中直接轮换 refresh token;找不到有效副本时会停止,提示你人工维护授权。后台 9 天主动刷新会单独申请跨节点刷新锁,拿不到锁就跳过本轮。
|
|
458
458
|
|
|
459
459
|
双主动流程:
|
|
460
460
|
|
|
461
461
|
- push:本节点登录、Codex 自动刷新或 `/auth refresh all` 成功并通过本机镜像验证后,会主动把较新的候选加密推送给 peer。
|
|
462
462
|
- pull:本节点切换或重载 auth 前如果发现本地候选不是最新,会先查本机其他 runtime;仍找不到时,再向 peer 拉取同名同账号的较新副本。
|
|
463
|
-
- lease:执行会旋转 refresh token 的 `/auth refresh all confirm`
|
|
463
|
+
- lease:执行会旋转 refresh token 的 `/auth refresh all confirm` 或后台 9 天主动刷新前,会向 peer 申请跨节点刷新锁。任一 peer 忙碌、拒绝或无响应都会阻止刷新。
|
|
464
464
|
|
|
465
465
|
命令:
|
|
466
466
|
|