@foxden-app/foxclaw 0.4.8 → 0.4.10
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 +18 -0
- package/dist/auth/mirror.d.ts +1 -0
- package/dist/auth/mirror.js +54 -44
- package/dist/controller/controller.js +1 -4
- package/dist/i18n.d.ts +0 -2
- package/dist/i18n.js +0 -2
- package/docs/user-manual.md +6 -4
- package/docs/zh/user-manual.md +6 -4
- package/package.json +1 -1
- package/skills/foxclaw/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
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.10 - 2026-06-02
|
|
6
|
+
|
|
7
|
+
### 中文
|
|
8
|
+
- 从 `/auth` 面板移除“刷新全部”按钮,避免高风险 refresh token 轮换操作被日常面板误触。
|
|
9
|
+
- 保留 `/auth refresh all` 和 `/auth refresh all confirm` 命令入口,继续要求显式风险确认后才会执行刷新全部。
|
|
10
|
+
|
|
11
|
+
### English
|
|
12
|
+
- Removed the Refresh all button from the `/auth` panel to avoid accidental use of the high-risk refresh-token rotation maintenance action.
|
|
13
|
+
- Kept `/auth refresh all` and `/auth refresh all confirm` as command entry points, still requiring explicit risk confirmation before refresh all runs.
|
|
14
|
+
|
|
15
|
+
## 0.4.9 - 2026-06-02
|
|
16
|
+
|
|
17
|
+
### 中文
|
|
18
|
+
- 修复 ChatGPT auth 到期自动刷新后,主动同步和后台镜像扫描竞态导致同一候选重复发送镜像广播的问题。
|
|
19
|
+
|
|
20
|
+
### English
|
|
21
|
+
- Fixed duplicate auth mirror broadcasts when an automatic ChatGPT auth refresh was observed by both the direct sync path and the background mirror scan.
|
|
22
|
+
|
|
5
23
|
## 0.4.8 - 2026-06-02
|
|
6
24
|
|
|
7
25
|
### 中文
|
package/dist/auth/mirror.d.ts
CHANGED
|
@@ -39,6 +39,7 @@ export declare class AuthCandidateMirror {
|
|
|
39
39
|
private timer;
|
|
40
40
|
private readonly lastSyncedRefresh;
|
|
41
41
|
private readonly lastValidationFailures;
|
|
42
|
+
private readonly activeCandidateSyncs;
|
|
42
43
|
private activeOperations;
|
|
43
44
|
private lastStatus;
|
|
44
45
|
constructor(canonicalDir: string, runtimes: AuthMirrorRuntime[], logger: Logger, statusPath?: string | null);
|
package/dist/auth/mirror.js
CHANGED
|
@@ -9,6 +9,7 @@ export class AuthCandidateMirror {
|
|
|
9
9
|
timer = null;
|
|
10
10
|
lastSyncedRefresh = new Map();
|
|
11
11
|
lastValidationFailures = new Map();
|
|
12
|
+
activeCandidateSyncs = new Set();
|
|
12
13
|
activeOperations = 0;
|
|
13
14
|
lastStatus = null;
|
|
14
15
|
constructor(canonicalDir, runtimes, logger, statusPath = null) {
|
|
@@ -116,55 +117,64 @@ export class AuthCandidateMirror {
|
|
|
116
117
|
});
|
|
117
118
|
}
|
|
118
119
|
async propagateValidatedCandidate(runtime, name) {
|
|
119
|
-
|
|
120
|
-
const record = await readChatGptAuthRecord(sourcePath);
|
|
121
|
-
if (!record)
|
|
122
|
-
return false;
|
|
123
|
-
const canonicalPath = path.join(this.canonicalDir, name);
|
|
124
|
-
const canonical = await readChatGptAuthRecord(canonicalPath);
|
|
125
|
-
if (canonical && canonical.accountId !== record.accountId) {
|
|
126
|
-
this.logger.warn('auth.mirror.account_conflict', { runtimeId: runtime.id, name });
|
|
127
|
-
return false;
|
|
128
|
-
}
|
|
129
|
-
const previousRefresh = Math.max(canonical?.lastRefreshMs ?? 0, this.lastSyncedRefresh.get(name) ?? 0);
|
|
130
|
-
if (record.lastRefreshMs <= previousRefresh) {
|
|
120
|
+
if (this.activeCandidateSyncs.has(name)) {
|
|
131
121
|
return false;
|
|
132
122
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
});
|
|
123
|
+
this.activeCandidateSyncs.add(name);
|
|
124
|
+
try {
|
|
125
|
+
const sourcePath = path.join(runtime.authDir, name);
|
|
126
|
+
const record = await readChatGptAuthRecord(sourcePath);
|
|
127
|
+
if (!record)
|
|
128
|
+
return false;
|
|
129
|
+
const canonicalPath = path.join(this.canonicalDir, name);
|
|
130
|
+
const canonical = await readChatGptAuthRecord(canonicalPath);
|
|
131
|
+
if (canonical && canonical.accountId !== record.accountId) {
|
|
132
|
+
this.logger.warn('auth.mirror.account_conflict', { runtimeId: runtime.id, name });
|
|
133
|
+
return false;
|
|
145
134
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (
|
|
152
|
-
|
|
135
|
+
const previousRefresh = Math.max(canonical?.lastRefreshMs ?? 0, this.lastSyncedRefresh.get(name) ?? 0);
|
|
136
|
+
if (record.lastRefreshMs <= previousRefresh) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
const validation = await this.validateRuntimeCandidate(runtime, name, record);
|
|
140
|
+
if (!validation.ok) {
|
|
141
|
+
const reason = validation.reason ?? 'unknown';
|
|
142
|
+
const failureKey = `${runtime.id}:${name}`;
|
|
143
|
+
const failureValue = `${record.lastRefreshMs}:${reason}`;
|
|
144
|
+
if (this.lastValidationFailures.get(failureKey) !== failureValue) {
|
|
145
|
+
this.lastValidationFailures.set(failureKey, failureValue);
|
|
146
|
+
this.logger.warn('auth.mirror.validation_failed', {
|
|
147
|
+
runtimeId: runtime.id,
|
|
148
|
+
name,
|
|
149
|
+
reason,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
153
|
}
|
|
154
|
+
this.lastValidationFailures.delete(`${runtime.id}:${name}`);
|
|
155
|
+
await atomicWrite(canonicalPath, record.raw);
|
|
156
|
+
for (const target of this.runtimes) {
|
|
157
|
+
if (target.id !== runtime.id) {
|
|
158
|
+
await atomicWrite(path.join(target.authDir, name), record.raw);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
this.lastSyncedRefresh.set(name, record.lastRefreshMs);
|
|
162
|
+
const sourceLabel = runtime.label ?? runtime.id;
|
|
163
|
+
this.lastStatus = {
|
|
164
|
+
candidateName: name,
|
|
165
|
+
sourceRuntimeId: runtime.id,
|
|
166
|
+
sourceLabel,
|
|
167
|
+
syncedAt: new Date().toISOString(),
|
|
168
|
+
};
|
|
169
|
+
await writeMirrorStatus(this.statusPath, this.lastStatus);
|
|
170
|
+
this.logger.info('auth.mirror.synced', { sourceRuntimeId: runtime.id, name });
|
|
171
|
+
const message = `${name} has been refreshed by ${sourceLabel} and synchronized to the other Codex homes.`;
|
|
172
|
+
await Promise.allSettled(this.runtimes.map((target) => target.notify?.(message)));
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
this.activeCandidateSyncs.delete(name);
|
|
154
177
|
}
|
|
155
|
-
this.lastSyncedRefresh.set(name, record.lastRefreshMs);
|
|
156
|
-
const sourceLabel = runtime.label ?? runtime.id;
|
|
157
|
-
this.lastStatus = {
|
|
158
|
-
candidateName: name,
|
|
159
|
-
sourceRuntimeId: runtime.id,
|
|
160
|
-
sourceLabel,
|
|
161
|
-
syncedAt: new Date().toISOString(),
|
|
162
|
-
};
|
|
163
|
-
await writeMirrorStatus(this.statusPath, this.lastStatus);
|
|
164
|
-
this.logger.info('auth.mirror.synced', { sourceRuntimeId: runtime.id, name });
|
|
165
|
-
const message = `${name} has been refreshed by ${sourceLabel} and synchronized to the other Codex homes.`;
|
|
166
|
-
await Promise.allSettled(this.runtimes.map((target) => target.notify?.(message)));
|
|
167
|
-
return true;
|
|
168
178
|
}
|
|
169
179
|
async validateRuntimeCandidate(runtime, name, record) {
|
|
170
180
|
if (!runtime.validate) {
|
|
@@ -7703,10 +7703,7 @@ function authChoiceKeyboard(locale, record) {
|
|
|
7703
7703
|
{ text: t(locale, 'button_permissions'), callback_data: 'nav:permissions' },
|
|
7704
7704
|
{ text: t(locale, 'button_login_device'), callback_data: `auth:${record.localId}:login_device` },
|
|
7705
7705
|
]);
|
|
7706
|
-
rows.push([
|
|
7707
|
-
{ text: t(locale, 'button_auth_reload'), callback_data: `auth:${record.localId}:reload` },
|
|
7708
|
-
{ text: t(locale, 'button_auth_refresh_all'), callback_data: `auth:${record.localId}:refresh_all` },
|
|
7709
|
-
]);
|
|
7706
|
+
rows.push([{ text: t(locale, 'button_auth_reload'), callback_data: `auth:${record.localId}:reload` }]);
|
|
7710
7707
|
return rows;
|
|
7711
7708
|
}
|
|
7712
7709
|
function authRefreshAllConfirmKeyboard(locale, record) {
|
package/dist/i18n.d.ts
CHANGED
|
@@ -182,7 +182,6 @@ declare const MESSAGES: {
|
|
|
182
182
|
readonly auth_add_missing_file: "Login completed, but the new auth file was not created: {value}";
|
|
183
183
|
readonly button_login_device: "🔑 Login";
|
|
184
184
|
readonly button_auth_reload: "🔄 Reload auth";
|
|
185
|
-
readonly button_auth_refresh_all: "🔁 Refresh all";
|
|
186
185
|
readonly button_auth_refresh_all_confirm: "⚠️ Accept risk & refresh";
|
|
187
186
|
readonly button_auth_enable: "✅";
|
|
188
187
|
readonly button_auth_disable: "⏸️";
|
|
@@ -769,7 +768,6 @@ declare const MESSAGES: {
|
|
|
769
768
|
readonly auth_add_missing_file: "登录已完成,但没有创建新的 auth 文件:{value}";
|
|
770
769
|
readonly button_login_device: "🔑 设备登录";
|
|
771
770
|
readonly button_auth_reload: "🔄 重载 auth";
|
|
772
|
-
readonly button_auth_refresh_all: "🔁 刷新全部";
|
|
773
771
|
readonly button_auth_refresh_all_confirm: "⚠️ 接受风险并刷新";
|
|
774
772
|
readonly button_auth_enable: "✅";
|
|
775
773
|
readonly button_auth_disable: "⏸️";
|
package/dist/i18n.js
CHANGED
|
@@ -180,7 +180,6 @@ const MESSAGES = {
|
|
|
180
180
|
auth_add_missing_file: 'Login completed, but the new auth file was not created: {value}',
|
|
181
181
|
button_login_device: '🔑 Login',
|
|
182
182
|
button_auth_reload: '🔄 Reload auth',
|
|
183
|
-
button_auth_refresh_all: '🔁 Refresh all',
|
|
184
183
|
button_auth_refresh_all_confirm: '⚠️ Accept risk & refresh',
|
|
185
184
|
button_auth_enable: '✅',
|
|
186
185
|
button_auth_disable: '⏸️',
|
|
@@ -767,7 +766,6 @@ const MESSAGES = {
|
|
|
767
766
|
auth_add_missing_file: '登录已完成,但没有创建新的 auth 文件:{value}',
|
|
768
767
|
button_login_device: '🔑 设备登录',
|
|
769
768
|
button_auth_reload: '🔄 重载 auth',
|
|
770
|
-
button_auth_refresh_all: '🔁 刷新全部',
|
|
771
769
|
button_auth_refresh_all_confirm: '⚠️ 接受风险并刷新',
|
|
772
770
|
button_auth_enable: '✅',
|
|
773
771
|
button_auth_disable: '⏸️',
|
package/docs/user-manual.md
CHANGED
|
@@ -389,7 +389,7 @@ If the login is cancelled or fails, FoxClaw tries to restore the previous auth t
|
|
|
389
389
|
|
|
390
390
|
### 6.3 The `/auth` Panel
|
|
391
391
|
|
|
392
|
-
`/auth` lists candidate accounts, the current account, and the auth directory. It also provides buttons for switching, disabling, login,
|
|
392
|
+
`/auth` lists candidate accounts, the current account, and the auth directory. It also provides buttons for switching, disabling, login, and reload. In multi-bot mode the panel names the `@botname` runtime being managed, because private chats, groups, and topics on one bot share that bot's current auth. The `5h|7d` numbers before each filename are the last recorded remaining percentages for the two quota windows; the current auth is refreshed when the panel opens, while other candidates are not switched merely to query quota. When multiple bot runtimes have recently used the same ChatGPT account, FoxClaw combines their cached quota snapshots by verified account ID, so one bot's `/auth` panel can show quota information learned by another bot without mixing different accounts.
|
|
393
393
|
|
|
394
394
|
Approximation:
|
|
395
395
|
|
|
@@ -405,10 +405,12 @@ Quota remaining: 5h|7d|auth
|
|
|
405
405
|
[✅ 20|25|auth.json_personal] [✅]
|
|
406
406
|
[🔐 --|--|auth.json_team] [✅]
|
|
407
407
|
[🛡️ Access] [🔑 Login]
|
|
408
|
-
[🔄 Reload auth]
|
|
408
|
+
[🔄 Reload auth]
|
|
409
409
|
```
|
|
410
410
|
|
|
411
|
-
The right-side `✅` / `⏸️` button shows the current state. Tapping it toggles enabled/disabled, and the refreshed list shows the new state. Tapping a candidate switches auth, restarts that runtime, and refreshes the same panel with its buttons intact so you can switch again immediately.
|
|
411
|
+
The right-side `✅` / `⏸️` button shows the current state. Tapping it toggles enabled/disabled, and the refreshed list shows the new state. Tapping a candidate switches auth, restarts that runtime, and refreshes the same panel with its buttons intact so you can switch again immediately. `--|--` means no quota snapshot has been observed for that candidate yet.
|
|
412
|
+
|
|
413
|
+
`/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.
|
|
412
414
|
|
|
413
415
|
Equivalent commands:
|
|
414
416
|
|
|
@@ -417,7 +419,7 @@ Equivalent commands:
|
|
|
417
419
|
- `/auth enable <n>`: let candidate n participate in auto-rotation.
|
|
418
420
|
- `/auth disable <n>`: skip candidate n during auto-rotation.
|
|
419
421
|
- `/auth reload` or `/auth_reload`: restart app-server and reload the current `auth.json`.
|
|
420
|
-
- `/auth refresh all`: show the
|
|
422
|
+
- `/auth refresh all`: show the refresh-token rotation risk confirmation.
|
|
421
423
|
- `/auth refresh all confirm`: run Refresh all after accepting the token-rotation risk.
|
|
422
424
|
|
|
423
425
|
If the requesting bot runtime has active turns, pending approvals, pending user inputs, or MCP elicitations, FoxClaw refuses manual auth switching to avoid changing accounts mid-request; another idle bot is unaffected.
|
package/docs/zh/user-manual.md
CHANGED
|
@@ -389,7 +389,7 @@ cp -L ~/.codex/auth.json ~/.codex/auth.json_personal
|
|
|
389
389
|
|
|
390
390
|
### 6.3 `/auth` 面板
|
|
391
391
|
|
|
392
|
-
`/auth` 会列出候选账号、当前账号和 auth
|
|
392
|
+
`/auth` 会列出候选账号、当前账号和 auth 目录,并提供按钮切换、禁用、登录和重载。多 bot 模式中,面板顶部还会显示当前正在管理的 `@botname`,因为该 bot 内的私聊、群聊和话题共享同一个当前 auth。每个候选名前的 `5h|7d` 数字表示上次记录到的两个额度窗口剩余百分比;当前 auth 会在打开面板时刷新,其他候选不会为了查询额度被自动切换。如果多个 bot runtime 最近使用过同一个 ChatGPT 账号,FoxClaw 会按已验证的账号 ID 合并它们缓存到的额度快照,因此一个 bot 的 `/auth` 面板可以显示另一个 bot 掌握到的额度信息,同时不会把不同账号混在一起。
|
|
393
393
|
|
|
394
394
|
示意:
|
|
395
395
|
|
|
@@ -405,10 +405,12 @@ Candidates: 2
|
|
|
405
405
|
[✅ 20|25|auth.json_personal] [✅]
|
|
406
406
|
[🔐 --|--|auth.json_team] [✅]
|
|
407
407
|
[🛡️ Access] [🔑 设备登录]
|
|
408
|
-
[🔄 Reload auth]
|
|
408
|
+
[🔄 Reload auth]
|
|
409
409
|
```
|
|
410
410
|
|
|
411
|
-
右侧 `✅` / `⏸️` 表示当前状态。点一下会切换启用/禁用,列表刷新后图标会随状态变化。点击候选会切换 auth、重启对应 runtime
|
|
411
|
+
右侧 `✅` / `⏸️` 表示当前状态。点一下会切换启用/禁用,列表刷新后图标会随状态变化。点击候选会切换 auth、重启对应 runtime,并在原消息上刷新面板且保留按钮,因此可以立即连续切换。`--|--` 表示该候选还没有额度历史快照。
|
|
412
|
+
|
|
413
|
+
`/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 并显示摘要。
|
|
412
414
|
|
|
413
415
|
命令等价用法:
|
|
414
416
|
|
|
@@ -417,7 +419,7 @@ Candidates: 2
|
|
|
417
419
|
- `/auth enable <n>`:让第 n 个候选参与自动轮转。
|
|
418
420
|
- `/auth disable <n>`:禁用第 n 个候选,自动轮转会跳过它。
|
|
419
421
|
- `/auth reload` 或 `/auth_reload`:重启 app-server,重新加载当前 `auth.json`。
|
|
420
|
-
- `/auth refresh all
|
|
422
|
+
- `/auth refresh all`:显示 refresh token 轮换风险确认。
|
|
421
423
|
- `/auth refresh all confirm`:接受 token 轮换风险后执行刷新全部。
|
|
422
424
|
|
|
423
425
|
切换 auth 时,如果当前 bot runtime 还有活跃 turn、待审批、待用户输入或 MCP elicitation,FoxClaw 会先拒绝切换,避免中途换号破坏正在进行的请求;另一个空闲 bot 不受影响。
|
package/package.json
CHANGED
package/skills/foxclaw/SKILL.md
CHANGED
|
@@ -67,7 +67,7 @@ Telegram behavior:
|
|
|
67
67
|
- when `TG_BOT_TOKENS` contains multiple bots, one FoxClaw service starts independent Codex app-servers and auth selections per bot
|
|
68
68
|
- isolated Telegram runtimes use independent `CODEX_HOME` directories and file-backed credentials; they do not share Codex sessions
|
|
69
69
|
- refreshed ChatGPT candidates are mirrored only after online usage-endpoint validation; before auth switch or reload, a runtime restores any newer same-account credential found in another Codex home
|
|
70
|
-
- `/auth`
|
|
70
|
+
- `/auth refresh all` remains a command-only maintenance action, not a panel button; it requires explicit token-rotation risk confirmation, then force-refreshes every ChatGPT candidate through Codex `account/read refreshToken=true`, validates usage, mirrors successful candidates, and restores the original current auth while every runtime is globally idle
|
|
71
71
|
- if `TG_BOT_TOKEN` is also set to one exact token from `TG_BOT_TOKENS`, that bot uses the default/shared-terminal runtime instead of an isolated Telegram home
|
|
72
72
|
- if Weixin is enabled alongside multiple Telegram bots, it remains on the default Codex runtime instead of borrowing a Telegram bot runtime
|
|
73
73
|
- in a group with multiple configured bots, address a bot by mention, reply, or suffixed command such as `/status@botname`
|