@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 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
  ### 中文
@@ -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);
@@ -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
- const sourcePath = path.join(runtime.authDir, name);
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
- const validation = await this.validateRuntimeCandidate(runtime, name, record);
134
- if (!validation.ok) {
135
- const reason = validation.reason ?? 'unknown';
136
- const failureKey = `${runtime.id}:${name}`;
137
- const failureValue = `${record.lastRefreshMs}:${reason}`;
138
- if (this.lastValidationFailures.get(failureKey) !== failureValue) {
139
- this.lastValidationFailures.set(failureKey, failureValue);
140
- this.logger.warn('auth.mirror.validation_failed', {
141
- runtimeId: runtime.id,
142
- name,
143
- reason,
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
- return false;
147
- }
148
- this.lastValidationFailures.delete(`${runtime.id}:${name}`);
149
- await atomicWrite(canonicalPath, record.raw);
150
- for (const target of this.runtimes) {
151
- if (target.id !== runtime.id) {
152
- await atomicWrite(path.join(target.authDir, name), record.raw);
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: '⏸️',
@@ -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, reload, and Refresh all. 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.
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] [🔁 Refresh all]
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. `Refresh all` is a maintenance action: it is allowed only when every Telegram runtime, the Weixin runtime, approvals, inputs, logins, and auth mirroring are idle. The first tap only shows a risk confirmation: ChatGPT refresh tokens are rotated, so 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 in the panel, 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 refreshes the panel with a summary. `--|--` means no quota snapshot has been observed for that candidate yet.
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 same risk confirmation as the panel's Refresh all button.
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.
@@ -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 目录,并提供按钮切换、禁用、登录、重载和刷新全部。多 bot 模式中,面板顶部还会显示当前正在管理的 `@botname`,因为该 bot 内的私聊、群聊和话题共享同一个当前 auth。每个候选名前的 `5h|7d` 数字表示上次记录到的两个额度窗口剩余百分比;当前 auth 会在打开面板时刷新,其他候选不会为了查询额度被自动切换。如果多个 bot runtime 最近使用过同一个 ChatGPT 账号,FoxClaw 会按已验证的账号 ID 合并它们缓存到的额度快照,因此一个 bot 的 `/auth` 面板可以显示另一个 bot 掌握到的额度信息,同时不会把不同账号混在一起。
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,并在原消息上刷新面板且保留按钮,因此可以立即连续切换。`刷新全部` 是维护操作:只有所有 Telegram runtime、微信 runtime、审批、待输入、登录流程和 auth 镜像写入都空闲时才允许执行。第一次点击只会显示风险确认:ChatGPT refresh token 会被轮换,如果 OpenAI/Codex 已经消费旧 refresh token,但因为网络、进程或磁盘故障导致新 token 没能成功保存,该候选可能需要重新设备登录,甚至重新手机号验证。确认后,它会逐个访问面板里的 ChatGPT 候选,让 Codex 通过 `account/read refreshToken=true` 强制刷新 token,再用 usage 接口验证,成功后镜像到其他 bot home,最后恢复原本的当前 auth 并刷新面板摘要。`--|--` 表示该候选还没有额度历史快照。
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
@@ -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` includes a Refresh all 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
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`