@foxden-app/foxclaw 0.5.16 → 0.5.18

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,26 @@
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.18 - 2026-06-08
6
+
7
+ ### 中文
8
+ - `/auth` 面板按钮和 `/auth use <n>` 手动切换后会立即通过 Codex app-server 验证新 auth:ChatGPT 候选必须能返回 account 和 rate-limit usage,成功后马上写入该候选的额度快照,所以列表不再继续显示旧额度。
9
+ - 如果切换后的 auth 无法读取、身份不匹配,或 Codex 没有返回有效 account/usage,FoxClaw 会把该候选标记为“需要登录修复”(`?`),恢复到切换前的 auth,并再次重启 Codex app-server,让当前 runtime 不停留在坏 auth 上。
10
+ - 自动 auth 轮换也复用同一套切换后验证;验证失败的候选不会继续重试请求或参与后续轮询。
11
+
12
+ ### English
13
+ - Manual auth switches from the `/auth` panel buttons and `/auth use <n>` now validate the newly selected auth through Codex app-server immediately. ChatGPT candidates must return account and rate-limit usage, and successful switches record a fresh quota snapshot so the list no longer keeps showing stale usage.
14
+ - If the selected auth cannot be parsed, has an identity mismatch, or Codex does not return valid account/usage data, FoxClaw marks that candidate as “needs login repair” (`?`), restores the previous auth, and restarts Codex app-server again so the runtime does not remain on a bad auth.
15
+ - Automatic auth rotation now uses the same post-switch validation; failed candidates are not used for retrying the request or for later polling.
16
+
17
+ ## 0.5.17 - 2026-06-08
18
+
19
+ ### 中文
20
+ - `foxclaw <subcommand> --help` / `-h` 现在只打印帮助,不再继续执行 `install-systemd`、`start`、`restart` 等带副作用的子命令,避免一次查帮助意外触发服务重启。
21
+
22
+ ### English
23
+ - `foxclaw <subcommand> --help` / `-h` now prints usage and stops before running side-effecting subcommands such as `install-systemd`, `start`, or `restart`, preventing an accidental service restart while checking help.
24
+
5
25
  ## 0.5.16 - 2026-06-08
6
26
 
7
27
  ### 中文
@@ -346,6 +346,8 @@ export declare class BridgeSessionCore {
346
346
  private readCodexAuthSwitchLabels;
347
347
  private codexAuthSwitchParams;
348
348
  private switchCodexAuthAndRestart;
349
+ private validateCurrentCodexAuthCandidate;
350
+ private formatAuthSwitchValidationLines;
349
351
  private recoverCodexAuthCandidate;
350
352
  private markCodexAuthCandidateNeedsRepair;
351
353
  private markCodexAuthCandidateActive;
@@ -5362,14 +5362,16 @@ export class BridgeSessionCore {
5362
5362
  if (record.messageId !== null) {
5363
5363
  await this.editMessage(event.scopeId, record.messageId, switchingMessage, []);
5364
5364
  }
5365
- await this.switchCodexAuthAndRestart(event.scopeId, locale, candidate, false, false);
5365
+ const outcome = await this.switchCodexAuthAndRestart(event.scopeId, locale, candidate, false, false);
5366
5366
  const state = await this.listCodexAuthState();
5367
- await this.refreshCurrentCodexAuthQuota(state);
5368
5367
  record.candidates = state.candidates;
5369
5368
  record.createdAt = Date.now();
5370
5369
  clampCodexAuthListOffset(record);
5371
5370
  if (record.messageId !== null) {
5372
- await this.editMessage(event.scopeId, record.messageId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null, record), authChoiceKeyboard(locale, record));
5371
+ await this.editMessage(event.scopeId, record.messageId, [
5372
+ ...this.formatAuthSwitchValidationLines(locale, outcome),
5373
+ renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null, record),
5374
+ ].filter(Boolean).join('\n\n'), authChoiceKeyboard(locale, record));
5373
5375
  }
5374
5376
  }
5375
5377
  async maybeRunPendingAuthRotation() {
@@ -5417,7 +5419,10 @@ export class BridgeSessionCore {
5417
5419
  ...this.codexAuthSwitchParams(locale, fromLabel, toLabel),
5418
5420
  error: formatShortStatusError(rotation.reason),
5419
5421
  }));
5420
- await this.switchCodexAuthAndRestart(rotation.scopeId, locale, candidate, true);
5422
+ const outcome = await this.switchCodexAuthAndRestart(rotation.scopeId, locale, candidate, true);
5423
+ if (!outcome.ok) {
5424
+ return false;
5425
+ }
5421
5426
  if (rotation.retry) {
5422
5427
  await this.retryTurnAfterAuthRotation(rotation.scopeId, locale, rotation.retry);
5423
5428
  return true;
@@ -5533,15 +5538,61 @@ export class BridgeSessionCore {
5533
5538
  };
5534
5539
  }
5535
5540
  async switchCodexAuthAndRestart(scopeId, locale, candidate, automatic, sendResult = true) {
5541
+ const authDir = this.resolveAuthDir();
5542
+ const initialState = await listCodexAuthState(new Set(), new Map(), authDir);
5543
+ const authStat = await fs.lstat(initialState.authPath).catch(() => null);
5544
+ const originalRegularAuth = authStat?.isFile()
5545
+ ? await fs.readFile(initialState.authPath, 'utf8').catch(() => null)
5546
+ : null;
5536
5547
  const recovered = await this.recoverCodexAuthCandidate(candidate.name, { crossNode: automatic });
5537
- const result = await switchCodexAuth(candidate.path, this.resolveAuthDir());
5548
+ const result = await switchCodexAuth(candidate.path, authDir);
5538
5549
  this.authRotationFailedTargets.delete(candidate.path);
5539
5550
  this.pendingTurnErrors.clear();
5540
5551
  this.attachedThreads.clear();
5541
5552
  await this.app.restart();
5553
+ const validation = await this.validateCurrentCodexAuthCandidate(candidate);
5554
+ if (!validation.ok) {
5555
+ this.markCodexAuthCandidateNeedsRepair(candidate.name);
5556
+ let restoredPrevious = false;
5557
+ try {
5558
+ await restoreCodexAuthTarget(initialState.authDir, initialState.authPath, initialState.currentTargetPath, originalRegularAuth);
5559
+ this.pendingTurnErrors.clear();
5560
+ this.attachedThreads.clear();
5561
+ await this.app.restart();
5562
+ restoredPrevious = true;
5563
+ }
5564
+ catch (error) {
5565
+ this.logger.warn('codex.auth_switch_restore_failed', {
5566
+ candidate: candidate.name,
5567
+ error: toErrorMeta(error),
5568
+ });
5569
+ }
5570
+ const outcome = {
5571
+ ...result,
5572
+ ok: false,
5573
+ candidateName: candidate.name,
5574
+ recovered,
5575
+ error: validation.error,
5576
+ restoredPrevious,
5577
+ };
5578
+ if (sendResult) {
5579
+ const lines = this.formatAuthSwitchValidationLines(locale, outcome);
5580
+ await this.sendMessage(scopeId, lines.join('\n'));
5581
+ }
5582
+ return outcome;
5583
+ }
5584
+ this.markCodexAuthCandidateActive(candidate.name);
5542
5585
  await this.syncCodexAuthCandidate(candidate.name);
5586
+ const outcome = {
5587
+ ...result,
5588
+ ok: true,
5589
+ candidateName: candidate.name,
5590
+ recovered,
5591
+ error: null,
5592
+ restoredPrevious: false,
5593
+ };
5543
5594
  if (!sendResult) {
5544
- return;
5595
+ return outcome;
5545
5596
  }
5546
5597
  const lines = [t(locale, automatic ? 'auth_auto_done' : 'auth_switch_done', this.codexAuthSwitchParams(locale, result.fromLabel, result.toLabel))];
5547
5598
  if (recovered) {
@@ -5549,6 +5600,56 @@ export class BridgeSessionCore {
5549
5600
  }
5550
5601
  lines.push(...await this.buildCodexUsageStatusLines(locale));
5551
5602
  await this.sendMessage(scopeId, lines.join('\n'));
5603
+ return outcome;
5604
+ }
5605
+ async validateCurrentCodexAuthCandidate(candidate) {
5606
+ try {
5607
+ if (await isCodexApiKeyAuthCandidate(candidate.path)) {
5608
+ const account = await this.app.readAccount(false);
5609
+ if (!account) {
5610
+ return { ok: false, error: 'Codex did not return account info after switch' };
5611
+ }
5612
+ return { ok: true };
5613
+ }
5614
+ const metadata = await readChatGptAuthMetadata(candidate.path);
5615
+ if (!metadata) {
5616
+ return { ok: false, error: 'candidate is not a readable ChatGPT auth file' };
5617
+ }
5618
+ if (!chatGptAuthMetadataMatchesCandidateName(candidate.name, metadata)) {
5619
+ return { ok: false, error: 'candidate auth identity does not match candidate name' };
5620
+ }
5621
+ const [account, rateLimits] = await Promise.all([
5622
+ this.app.readAccount(false),
5623
+ this.app.readAccountRateLimits(),
5624
+ ]);
5625
+ if (!account) {
5626
+ return { ok: false, error: 'Codex did not return account info after switch' };
5627
+ }
5628
+ if (account.type !== 'chatgpt') {
5629
+ return { ok: false, error: `Codex account type is ${account.type || 'unknown'}, expected ChatGPT` };
5630
+ }
5631
+ const snapshot = selectCodexRateLimitSnapshot(rateLimits);
5632
+ if (!snapshot) {
5633
+ return { ok: false, error: 'Codex did not return ChatGPT rate limits after switch' };
5634
+ }
5635
+ await this.recordCodexAuthQuotaSnapshot(candidate.name, metadata, snapshot);
5636
+ return { ok: true };
5637
+ }
5638
+ catch (error) {
5639
+ return { ok: false, error: formatUserError(error) };
5640
+ }
5641
+ }
5642
+ formatAuthSwitchValidationLines(locale, outcome) {
5643
+ if (outcome.ok) {
5644
+ return [];
5645
+ }
5646
+ return [
5647
+ t(locale, 'auth_switch_validation_failed', {
5648
+ value: outcome.candidateName,
5649
+ error: outcome.error ?? t(locale, 'unknown'),
5650
+ }),
5651
+ outcome.restoredPrevious ? t(locale, 'auth_switch_validation_reverted') : '',
5652
+ ].filter(Boolean);
5552
5653
  }
5553
5654
  async recoverCodexAuthCandidate(candidateName, options = { crossNode: true }) {
5554
5655
  if (!this.coordinator?.recoverAuthCandidate) {
package/dist/i18n.d.ts CHANGED
@@ -194,6 +194,8 @@ declare const MESSAGES: {
194
194
  readonly auth_choice_recorded: "Auth selected";
195
195
  readonly auth_switching: "Switching Codex auth: {from} -> {to}...";
196
196
  readonly auth_switch_done: "Codex auth switched: {from} -> {to}.";
197
+ readonly auth_switch_validation_failed: "Selected auth failed validation: {error}. Marked {value} for login repair.";
198
+ readonly auth_switch_validation_reverted: "Restored the previous auth after the failed switch.";
197
199
  readonly auth_recovered_newer_candidate: "Recovered a newer same-account credential for {value} from another Codex home before reload.";
198
200
  readonly auth_refresh_all_confirm_short: "Review refresh all risk first.";
199
201
  readonly auth_refresh_all_confirm_message: "Refresh all will rotate ChatGPT refresh tokens for every candidate. If OpenAI/Codex consumes a 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.\n\nOnly continue when every bot is idle and you accept this risk.";
@@ -866,6 +868,8 @@ declare const MESSAGES: {
866
868
  readonly auth_choice_recorded: "已选择 auth";
867
869
  readonly auth_switching: "正在切换 Codex auth:{from} -> {to}...";
868
870
  readonly auth_switch_done: "Codex auth 已切换:{from} -> {to}。";
871
+ readonly auth_switch_validation_failed: "选中的 auth 验证失败:{error}。已将 {value} 标记为需要登录修复。";
872
+ readonly auth_switch_validation_reverted: "已在切换失败后恢复到之前的 auth。";
869
873
  readonly auth_recovered_newer_candidate: "重载前已从其他 Codex home 恢复 {value} 的同账号较新凭据。";
870
874
  readonly auth_refresh_all_confirm_short: "请先确认刷新全部风险。";
871
875
  readonly auth_refresh_all_confirm_message: "刷新全部会轮换每个 ChatGPT 候选的 refresh token。如果 OpenAI/Codex 已经消费旧 refresh token,但因为网络、进程或磁盘故障导致新 token 没能成功保存,该候选可能需要重新设备登录,甚至重新手机号验证。\n\n只有在所有 bot 都空闲,并且你能接受这个风险时,才继续。";
package/dist/i18n.js CHANGED
@@ -192,6 +192,8 @@ const MESSAGES = {
192
192
  auth_choice_recorded: 'Auth selected',
193
193
  auth_switching: 'Switching Codex auth: {from} -> {to}...',
194
194
  auth_switch_done: 'Codex auth switched: {from} -> {to}.',
195
+ auth_switch_validation_failed: 'Selected auth failed validation: {error}. Marked {value} for login repair.',
196
+ auth_switch_validation_reverted: 'Restored the previous auth after the failed switch.',
195
197
  auth_recovered_newer_candidate: 'Recovered a newer same-account credential for {value} from another Codex home before reload.',
196
198
  auth_refresh_all_confirm_short: 'Review refresh all risk first.',
197
199
  auth_refresh_all_confirm_message: 'Refresh all will rotate ChatGPT refresh tokens for every candidate. If OpenAI/Codex consumes a 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.\n\nOnly continue when every bot is idle and you accept this risk.',
@@ -864,6 +866,8 @@ const MESSAGES = {
864
866
  auth_choice_recorded: '已选择 auth',
865
867
  auth_switching: '正在切换 Codex auth:{from} -> {to}...',
866
868
  auth_switch_done: 'Codex auth 已切换:{from} -> {to}。',
869
+ auth_switch_validation_failed: '选中的 auth 验证失败:{error}。已将 {value} 标记为需要登录修复。',
870
+ auth_switch_validation_reverted: '已在切换失败后恢复到之前的 auth。',
867
871
  auth_recovered_newer_candidate: '重载前已从其他 Codex home 恢复 {value} 的同账号较新凭据。',
868
872
  auth_refresh_all_confirm_short: '请先确认刷新全部风险。',
869
873
  auth_refresh_all_confirm_message: '刷新全部会轮换每个 ChatGPT 候选的 refresh token。如果 OpenAI/Codex 已经消费旧 refresh token,但因为网络、进程或磁盘故障导致新 token 没能成功保存,该候选可能需要重新设备登录,甚至重新手机号验证。\n\n只有在所有 bot 都空闲,并且你能接受这个风险时,才继续。',
package/dist/main.js CHANGED
@@ -66,7 +66,7 @@ async function main() {
66
66
  console.log(readPackageVersion());
67
67
  return;
68
68
  }
69
- if (isHelpCommand(command)) {
69
+ if (isHelpCommand(command) || hasHelpFlag()) {
70
70
  printUsage();
71
71
  return;
72
72
  }
@@ -149,6 +149,9 @@ function isVersionCommand(value) {
149
149
  function isHelpCommand(value) {
150
150
  return value === 'help' || value === '--help' || value === '-h';
151
151
  }
152
+ function hasHelpFlag() {
153
+ return process.argv.slice(3).some(isHelpCommand);
154
+ }
152
155
  function readPackageVersion() {
153
156
  try {
154
157
  const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.5.16",
3
+ "version": "0.5.18",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",