@foxden-app/foxclaw 0.5.13 → 0.5.15

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,28 @@
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.15 - 2026-06-08
6
+
7
+ ### 中文
8
+ - `/auth` 现在会把已确认不可用、并且本机/跨节点同步恢复失败的候选标记为“需要登录修复”,用 `?` 按钮显示,并从自动轮换、主动刷新和 enabled 视图中排除。
9
+ - 点击 `?` 会进入修复菜单,可选择“登录修复”对该候选执行设备码登录,成功后清除修复状态并重新参与轮换;也可选择“删除”,从 canonical 和所有本机 bot runtime 中删除该候选并清理额度缓存。
10
+ - 删除 auth 候选现在走 auth mirror 统一删除,避免只删一个 runtime 后又被其他 runtime 或 canonical 副本恢复。
11
+
12
+ ### English
13
+ - `/auth` now marks candidates that have been proven unusable and could not be recovered through local/cross-node sync as “needs login repair”, shows a `?` action, and excludes them from auto-rotation, proactive refresh, and the enabled filter.
14
+ - Tapping `?` opens a repair menu: Login repair runs device-code login for that candidate and clears the repair state on success; Delete removes the candidate from canonical storage and all local bot runtimes while clearing quota cache.
15
+ - Auth candidate deletion now flows through the auth mirror so deleting a candidate from one runtime is not undone by another runtime or canonical copy.
16
+
17
+ ## 0.5.14 - 2026-06-08
18
+
19
+ ### 中文
20
+ - Linux `foxclaw start` / `foxclaw restart` / `install-systemd` 现在如果检测到自己正运行在 `foxclaw.service` cgroup 内,会通过一次性的 `systemd-run --user` helper 在服务外执行重启,避免命令执行者被自己重启时杀掉导致半截输出或不确定状态。
21
+ - 继续保留 systemd 作为稳定守护层:主 service 仍由 `Restart=always`、`KillMode=control-group` 和 user linger 保活;重启编排则交给短生命周期 helper,避免再引入一个更脆弱的常驻 Node 守护进程。
22
+
23
+ ### English
24
+ - On Linux, `foxclaw start`, `foxclaw restart`, and `install-systemd` now detect when they are running inside the `foxclaw.service` cgroup and delegate the actual restart to a one-shot `systemd-run --user` helper outside that cgroup, avoiding half-written output or uncertain state when the caller would otherwise kill itself.
25
+ - systemd remains the stable supervisor with `Restart=always`, `KillMode=control-group`, and user linger; restart orchestration moves to a short-lived helper instead of adding another long-running Node watchdog.
26
+
5
27
  ## 0.5.13 - 2026-06-08
6
28
 
7
29
  ### 中文
@@ -84,6 +84,7 @@ export declare class AuthCandidateMirror {
84
84
  readRuntimeCandidate(runtimeId: string, candidateName: string): Promise<AuthMirrorCandidateRecord | null>;
85
85
  listNewestCandidates(): Promise<AuthMirrorCandidateRecord[]>;
86
86
  syncRuntimeCandidate(runtimeId: string, candidateName: string): Promise<boolean>;
87
+ deleteCandidate(candidateName: string): Promise<boolean>;
87
88
  syncAllRuntimeCandidates(): Promise<AuthMirrorSyncAllResult>;
88
89
  recoverRuntimeCandidate(runtimeId: string, candidateName: string): Promise<AuthMirrorRecovery | null>;
89
90
  private scan;
@@ -126,6 +126,27 @@ export class AuthCandidateMirror {
126
126
  return false;
127
127
  return this.withActivity(() => this.propagateValidatedCandidate(runtime, candidateName));
128
128
  }
129
+ async deleteCandidate(candidateName) {
130
+ if (!isAuthCandidateName(candidateName))
131
+ return false;
132
+ return this.withActivity(async () => {
133
+ const directories = [
134
+ this.canonicalDir,
135
+ ...this.runtimes.map(runtime => runtime.authDir),
136
+ ];
137
+ for (const authDir of directories) {
138
+ await removeAuthCandidate(authDir, candidateName);
139
+ }
140
+ this.lastSyncedRefresh.delete(candidateName);
141
+ for (const key of [...this.lastValidationFailures.keys()]) {
142
+ if (key.endsWith(`:${candidateName}`)) {
143
+ this.lastValidationFailures.delete(key);
144
+ }
145
+ }
146
+ this.logger.info('auth.mirror.deleted', { candidateName });
147
+ return true;
148
+ });
149
+ }
129
150
  async syncAllRuntimeCandidates() {
130
151
  return this.withActivity(async () => {
131
152
  let synced = 0;
@@ -687,6 +708,24 @@ async function pointAuthSymlink(dir, candidateName) {
687
708
  await fs.symlink(path.join(dir, candidateName), temporary);
688
709
  await fs.rename(temporary, path.join(dir, 'auth.json'));
689
710
  }
711
+ async function removeAuthCandidate(dir, candidateName) {
712
+ const candidatePath = path.join(dir, candidateName);
713
+ const authPath = path.join(dir, 'auth.json');
714
+ const currentTarget = await resolveCurrentAuthCandidatePath(authPath);
715
+ await fs.rm(candidatePath, { force: true }).catch(() => undefined);
716
+ if (currentTarget === candidatePath) {
717
+ await fs.rm(authPath, { force: true }).catch(() => undefined);
718
+ }
719
+ }
720
+ async function resolveCurrentAuthCandidatePath(authPath) {
721
+ const stat = await fs.lstat(authPath).catch(() => null);
722
+ if (!stat)
723
+ return null;
724
+ if (stat.isSymbolicLink()) {
725
+ return resolveFinalPath(authPath);
726
+ }
727
+ return null;
728
+ }
690
729
  async function removeValidationTempFiles(dir) {
691
730
  const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
692
731
  await Promise.all(entries
@@ -9,6 +9,7 @@ import type { SelfUpdateRuntime, SelfUpdateStatus } from '../update.js';
9
9
  export interface CoreCoordinator {
10
10
  canSelfUpdate?: () => boolean;
11
11
  authCandidateUpdated?: (runtimeId: string, candidateName: string) => Promise<void>;
12
+ authCandidateDeleted?: (runtimeId: string, candidateName: string) => Promise<void>;
12
13
  recoverAuthCandidate?: (runtimeId: string, candidateName: string, options?: {
13
14
  crossNode?: boolean;
14
15
  }) => Promise<boolean>;
@@ -295,6 +296,8 @@ export declare class BridgeSessionCore {
295
296
  private handleAuthToggleCommand;
296
297
  private findLatestAuthChoiceList;
297
298
  private handleAuthAddCommand;
299
+ private startAuthRepairLogin;
300
+ private deleteCodexAuthCandidate;
298
301
  private handleAccountCommand;
299
302
  private handleQuotaCommand;
300
303
  private handleQuotaNudgeCommand;
@@ -331,6 +334,8 @@ export declare class BridgeSessionCore {
331
334
  private requireReadyBinding;
332
335
  private handleAuthPanelActionCallback;
333
336
  private handleAuthListViewCallback;
337
+ private handleAuthRepairMenuCallback;
338
+ private handleAuthRepairActionCallback;
334
339
  private handleAuthToggleCallback;
335
340
  private handleAuthSwitchCallback;
336
341
  private maybeRunPendingAuthRotation;
@@ -342,6 +347,8 @@ export declare class BridgeSessionCore {
342
347
  private codexAuthSwitchParams;
343
348
  private switchCodexAuthAndRestart;
344
349
  private recoverCodexAuthCandidate;
350
+ private markCodexAuthCandidateNeedsRepair;
351
+ private markCodexAuthCandidateActive;
345
352
  private syncCodexAuthCandidate;
346
353
  private refreshAllCodexAuthCandidates;
347
354
  private refreshCodexAuthCandidates;
@@ -1045,6 +1045,16 @@ export class BridgeSessionCore {
1045
1045
  await this.handleSettingsCallback(event, settingsMatch[1], settingsMatch[2], locale);
1046
1046
  return;
1047
1047
  }
1048
+ const authRepairActionMatch = /^auth:([a-f0-9]+):repair_(login|delete|cancel):(\d+)$/.exec(event.data);
1049
+ if (authRepairActionMatch) {
1050
+ await this.handleAuthRepairActionCallback(event, authRepairActionMatch[1], authRepairActionMatch[2], Number.parseInt(authRepairActionMatch[3], 10), locale);
1051
+ return;
1052
+ }
1053
+ const authRepairMatch = /^auth:([a-f0-9]+):repair:(\d+)$/.exec(event.data);
1054
+ if (authRepairMatch) {
1055
+ await this.handleAuthRepairMenuCallback(event, authRepairMatch[1], Number.parseInt(authRepairMatch[2], 10), locale);
1056
+ return;
1057
+ }
1048
1058
  const authToggleMatch = /^auth:([a-f0-9]+):toggle:(\d+)$/.exec(event.data);
1049
1059
  if (authToggleMatch) {
1050
1060
  await this.handleAuthToggleCallback(event, authToggleMatch[1], Number.parseInt(authToggleMatch[2], 10), locale);
@@ -1657,8 +1667,11 @@ export class BridgeSessionCore {
1657
1667
  if (!success) {
1658
1668
  await this.restorePendingAuthAdd(pendingAuthAdd);
1659
1669
  await this.sendMessage(scopeId, [
1660
- t(locale, 'auth_add_failed', { value: pendingAuthAdd.name, error: params?.error ?? t(locale, 'unknown') }),
1661
- t(locale, 'auth_add_reverted'),
1670
+ t(locale, pendingAuthAdd.mode === 'repair' ? 'auth_repair_failed' : 'auth_add_failed', {
1671
+ value: pendingAuthAdd.name,
1672
+ error: params?.error ?? t(locale, 'unknown'),
1673
+ }),
1674
+ t(locale, pendingAuthAdd.mode === 'repair' ? 'auth_repair_reverted' : 'auth_add_reverted'),
1662
1675
  ].join('\n'));
1663
1676
  return;
1664
1677
  }
@@ -1666,12 +1679,26 @@ export class BridgeSessionCore {
1666
1679
  if (!stat?.isFile()) {
1667
1680
  await this.restorePendingAuthAdd(pendingAuthAdd);
1668
1681
  await this.sendMessage(scopeId, [
1669
- t(locale, 'auth_add_missing_file', { value: pendingAuthAdd.name }),
1670
- t(locale, 'auth_add_reverted'),
1682
+ t(locale, pendingAuthAdd.mode === 'repair' ? 'auth_repair_missing_file' : 'auth_add_missing_file', { value: pendingAuthAdd.name }),
1683
+ t(locale, pendingAuthAdd.mode === 'repair' ? 'auth_repair_reverted' : 'auth_add_reverted'),
1671
1684
  ].join('\n'));
1672
1685
  return;
1673
1686
  }
1674
- const lines = [t(locale, 'auth_add_done', { value: pendingAuthAdd.name })];
1687
+ if (pendingAuthAdd.mode === 'repair') {
1688
+ const metadata = await readChatGptAuthMetadata(pendingAuthAdd.path);
1689
+ if (!metadata || !chatGptAuthMetadataMatchesCandidateName(pendingAuthAdd.name, metadata)) {
1690
+ await this.restorePendingAuthAdd(pendingAuthAdd);
1691
+ await this.sendMessage(scopeId, [
1692
+ t(locale, 'auth_repair_identity_mismatch', { value: pendingAuthAdd.name }),
1693
+ t(locale, 'auth_repair_reverted'),
1694
+ ].join('\n'));
1695
+ return;
1696
+ }
1697
+ this.markCodexAuthCandidateActive(pendingAuthAdd.name);
1698
+ this.store.setCodexAuthCandidateDisabled(pendingAuthAdd.name, false);
1699
+ this.store.setCodexAuthCandidateDisabled(pendingAuthAdd.name, false, this.authRuntimeId());
1700
+ }
1701
+ const lines = [t(locale, pendingAuthAdd.mode === 'repair' ? 'auth_repair_done' : 'auth_add_done', { value: pendingAuthAdd.name })];
1675
1702
  try {
1676
1703
  await this.coordinator?.authCandidateUpdated?.(this.authRuntimeId(), pendingAuthAdd.name);
1677
1704
  }
@@ -1692,6 +1719,7 @@ export class BridgeSessionCore {
1692
1719
  }
1693
1720
  const currentCandidate = (await this.listCodexAuthState()).candidates.find(candidate => candidate.isCurrent) ?? null;
1694
1721
  if (currentCandidate) {
1722
+ this.markCodexAuthCandidateActive(currentCandidate.name);
1695
1723
  await this.syncCodexAuthCandidate(currentCandidate.name);
1696
1724
  }
1697
1725
  await this.sendMessage(scopeId, t(locale, 'login_completed'));
@@ -4340,6 +4368,7 @@ export class BridgeSessionCore {
4340
4368
  }
4341
4369
  const state = await this.listCodexAuthState();
4342
4370
  const dueCandidates = state.candidates.filter(candidate => !candidate.disabled
4371
+ && candidate.state !== 'needs_repair'
4343
4372
  && candidate.credentialKind === 'chatgpt'
4344
4373
  && candidate.credentialLastRefreshMs !== null
4345
4374
  && candidate.credentialLastRefreshMs <= Date.now() - CODEX_AUTH_PROACTIVE_REFRESH_DAYS * 24 * 60 * 60_000);
@@ -4553,6 +4582,10 @@ export class BridgeSessionCore {
4553
4582
  await this.sendMessage(scopeId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(scopeId) !== null));
4554
4583
  return;
4555
4584
  }
4585
+ if (candidate.state === 'needs_repair') {
4586
+ await this.sendMessage(scopeId, t(locale, 'auth_candidate_needs_repair', { value: candidate.name }));
4587
+ return;
4588
+ }
4556
4589
  const switchLabels = await this.readCodexAuthSwitchLabels(candidate);
4557
4590
  await this.sendMessage(scopeId, t(locale, 'auth_switching', this.codexAuthSwitchParams(locale, switchLabels.fromLabel, switchLabels.toLabel)));
4558
4591
  await this.switchCodexAuthAndRestart(scopeId, locale, candidate, false);
@@ -4625,6 +4658,7 @@ export class BridgeSessionCore {
4625
4658
  name: candidateName,
4626
4659
  path: targetPath,
4627
4660
  previousTargetPath: state.currentTargetPath,
4661
+ mode: 'add',
4628
4662
  createdAt: Date.now(),
4629
4663
  });
4630
4664
  await this.sendMessage(scopeId, [
@@ -4641,6 +4675,85 @@ export class BridgeSessionCore {
4641
4675
  throw error;
4642
4676
  }
4643
4677
  }
4678
+ async startAuthRepairLogin(scopeId, locale, candidate) {
4679
+ const state = await this.listCodexAuthState();
4680
+ const target = state.candidates.find(entry => entry.name === candidate.name) ?? null;
4681
+ if (!target) {
4682
+ await this.sendMessage(scopeId, t(locale, 'auth_choice_expired'));
4683
+ return;
4684
+ }
4685
+ await pointCodexAuthAtTarget(state.authDir, state.authPath, target.path);
4686
+ this.pendingTurnErrors.clear();
4687
+ this.attachedThreads.clear();
4688
+ try {
4689
+ await this.app.restart();
4690
+ const login = await this.app.startDeviceLogin();
4691
+ const oldLoginId = this.pendingLoginsByScope.get(scopeId);
4692
+ if (oldLoginId) {
4693
+ this.pendingLoginScopesById.delete(oldLoginId);
4694
+ this.pendingAuthAddsByLoginId.delete(oldLoginId);
4695
+ }
4696
+ this.pendingLoginsByScope.set(scopeId, login.loginId);
4697
+ this.pendingLoginScopesById.set(login.loginId, scopeId);
4698
+ this.pendingAuthAddsByLoginId.set(login.loginId, {
4699
+ loginId: login.loginId,
4700
+ scopeId,
4701
+ name: target.name,
4702
+ path: target.path,
4703
+ previousTargetPath: state.currentTargetPath,
4704
+ mode: 'repair',
4705
+ createdAt: Date.now(),
4706
+ });
4707
+ await this.sendMessage(scopeId, [
4708
+ t(locale, 'auth_repair_started', { value: target.name }),
4709
+ t(locale, 'login_device_prereq'),
4710
+ t(locale, 'login_url', { value: login.verificationUrl }),
4711
+ t(locale, 'login_code', { value: login.userCode }),
4712
+ t(locale, 'login_id', { value: login.loginId }),
4713
+ t(locale, 'login_cancel_hint', { value: login.loginId }),
4714
+ ].join('\n'));
4715
+ }
4716
+ catch (error) {
4717
+ await this.restoreAuthAfterAddFailure(state.authDir, state.authPath, state.currentTargetPath);
4718
+ throw error;
4719
+ }
4720
+ }
4721
+ async deleteCodexAuthCandidate(candidate) {
4722
+ const wasCurrent = candidate.isCurrent;
4723
+ const authDir = this.resolveAuthDir();
4724
+ const authPath = path.join(authDir, 'auth.json');
4725
+ let deletedByCoordinator = false;
4726
+ try {
4727
+ await this.coordinator?.authCandidateDeleted?.(this.authRuntimeId(), candidate.name);
4728
+ deletedByCoordinator = Boolean(this.coordinator?.authCandidateDeleted);
4729
+ }
4730
+ catch (error) {
4731
+ this.logger.warn('codex.auth_candidate_delete_sync_failed', {
4732
+ candidate: candidate.name,
4733
+ runtimeId: this.authRuntimeId(),
4734
+ error: toErrorMeta(error),
4735
+ });
4736
+ }
4737
+ if (!deletedByCoordinator) {
4738
+ await fs.rm(candidate.path, { force: true }).catch(() => undefined);
4739
+ if (wasCurrent) {
4740
+ await fs.rm(authPath, { force: true }).catch(() => undefined);
4741
+ }
4742
+ }
4743
+ this.store.deleteCodexAuthCandidate(candidate.name);
4744
+ this.authRotationFailedTargets.delete(candidate.path);
4745
+ const snapshots = await this.readCodexAuthQuotaSnapshots();
4746
+ if (Object.prototype.hasOwnProperty.call(snapshots, candidate.name)) {
4747
+ delete snapshots[candidate.name];
4748
+ await this.writeCodexAuthQuotaSnapshots();
4749
+ }
4750
+ if (wasCurrent) {
4751
+ this.pendingTurnErrors.clear();
4752
+ this.attachedThreads.clear();
4753
+ await this.app.restart();
4754
+ }
4755
+ return wasCurrent;
4756
+ }
4644
4757
  async handleAccountCommand(scopeId, locale) {
4645
4758
  const account = await this.app.readAccount();
4646
4759
  const lines = [
@@ -5126,6 +5239,74 @@ export class BridgeSessionCore {
5126
5239
  await this.editMessage(event.scopeId, record.messageId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null, record), authChoiceKeyboard(locale, record));
5127
5240
  }
5128
5241
  }
5242
+ async handleAuthRepairMenuCallback(event, localId, index, locale) {
5243
+ const record = this.pendingAuthChoiceLists.get(localId);
5244
+ if (!record) {
5245
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_choice_expired'));
5246
+ return;
5247
+ }
5248
+ if (record.chatId !== event.scopeId || (record.messageId !== null && record.messageId !== event.messageId)) {
5249
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_choice_mismatch'));
5250
+ return;
5251
+ }
5252
+ const candidate = record.candidates[index];
5253
+ if (!candidate) {
5254
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'unsupported_action'));
5255
+ return;
5256
+ }
5257
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_repair_actions_short'));
5258
+ if (record.messageId !== null) {
5259
+ await this.editMessage(event.scopeId, record.messageId, t(locale, 'auth_repair_actions_message', { value: formatCodexAuthCandidateDisplayName(candidate.name) }), authRepairKeyboard(locale, record, index));
5260
+ }
5261
+ }
5262
+ async handleAuthRepairActionCallback(event, localId, action, index, locale) {
5263
+ const record = this.pendingAuthChoiceLists.get(localId);
5264
+ if (!record) {
5265
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_choice_expired'));
5266
+ return;
5267
+ }
5268
+ if (record.chatId !== event.scopeId || (record.messageId !== null && record.messageId !== event.messageId)) {
5269
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_choice_mismatch'));
5270
+ return;
5271
+ }
5272
+ const candidate = record.candidates[index];
5273
+ if (!candidate) {
5274
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'unsupported_action'));
5275
+ return;
5276
+ }
5277
+ if (action === 'cancel') {
5278
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'decision_recorded'));
5279
+ const state = await this.listCodexAuthState();
5280
+ record.candidates = state.candidates;
5281
+ record.createdAt = Date.now();
5282
+ clampCodexAuthListOffset(record);
5283
+ if (record.messageId !== null) {
5284
+ await this.editMessage(event.scopeId, record.messageId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null, record), authChoiceKeyboard(locale, record));
5285
+ }
5286
+ return;
5287
+ }
5288
+ if (this.hasLocalBlockingActivity()) {
5289
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_reload_blocked_active'));
5290
+ return;
5291
+ }
5292
+ if (action === 'login') {
5293
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'login_device_started'));
5294
+ if (record.messageId !== null) {
5295
+ await this.editMessage(event.scopeId, record.messageId, t(locale, 'auth_repair_login_preparing', { value: candidate.name }), []);
5296
+ }
5297
+ await this.startAuthRepairLogin(event.scopeId, locale, candidate);
5298
+ return;
5299
+ }
5300
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_candidate_deleted_short'));
5301
+ const restarted = await this.deleteCodexAuthCandidate(candidate);
5302
+ const state = await this.listCodexAuthState();
5303
+ record.candidates = state.candidates;
5304
+ record.createdAt = Date.now();
5305
+ clampCodexAuthListOffset(record);
5306
+ if (record.messageId !== null) {
5307
+ await this.editMessage(event.scopeId, record.messageId, `${t(locale, 'auth_candidate_deleted', { value: candidate.name })}${restarted ? `\n${t(locale, 'auth_delete_current_restarted')}` : ''}\n\n${renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null, record)}`, authChoiceKeyboard(locale, record));
5308
+ }
5309
+ }
5129
5310
  async handleAuthToggleCallback(event, localId, index, locale) {
5130
5311
  const record = this.pendingAuthChoiceLists.get(localId);
5131
5312
  if (!record) {
@@ -5170,6 +5351,10 @@ export class BridgeSessionCore {
5170
5351
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'unsupported_action'));
5171
5352
  return;
5172
5353
  }
5354
+ if (candidate.state === 'needs_repair') {
5355
+ await this.handleAuthRepairMenuCallback(event, localId, index, locale);
5356
+ return;
5357
+ }
5173
5358
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_choice_recorded'));
5174
5359
  const switchLabels = await this.readCodexAuthSwitchLabels(candidate);
5175
5360
  const switchingMessage = t(locale, 'auth_switching', this.codexAuthSwitchParams(locale, switchLabels.fromLabel, switchLabels.toLabel));
@@ -5217,6 +5402,7 @@ export class BridgeSessionCore {
5217
5402
  }
5218
5403
  return false;
5219
5404
  }
5405
+ this.markCodexAuthCandidateNeedsRepair(current.name);
5220
5406
  }
5221
5407
  const selection = await this.selectNextCodexAuthCandidate(failedTargets);
5222
5408
  if (!selection) {
@@ -5291,7 +5477,9 @@ export class BridgeSessionCore {
5291
5477
  if (state.currentTargetPath) {
5292
5478
  failedTargets.add(state.currentTargetPath);
5293
5479
  }
5294
- const candidates = state.candidates.filter(candidate => !candidate.disabled && !failedTargets.has(candidate.path));
5480
+ const candidates = state.candidates.filter(candidate => !candidate.disabled
5481
+ && candidate.state !== 'needs_repair'
5482
+ && !failedTargets.has(candidate.path));
5295
5483
  if (candidates.length === 0) {
5296
5484
  return null;
5297
5485
  }
@@ -5300,7 +5488,10 @@ export class BridgeSessionCore {
5300
5488
  : -1;
5301
5489
  for (let offset = 1; offset <= state.candidates.length; offset += 1) {
5302
5490
  const candidate = state.candidates[(currentIndex + offset + state.candidates.length) % state.candidates.length];
5303
- if (candidate && !candidate.disabled && !failedTargets.has(candidate.path)) {
5491
+ if (candidate
5492
+ && !candidate.disabled
5493
+ && candidate.state !== 'needs_repair'
5494
+ && !failedTargets.has(candidate.path)) {
5304
5495
  return { candidate, fromLabel: state.currentLabel, toLabel: await authPathDisplayLabel(candidate.path) };
5305
5496
  }
5306
5497
  }
@@ -5308,7 +5499,7 @@ export class BridgeSessionCore {
5308
5499
  return candidate ? { candidate, fromLabel: state.currentLabel, toLabel: await authPathDisplayLabel(candidate.path) } : null;
5309
5500
  }
5310
5501
  async listCodexAuthState() {
5311
- const state = await listCodexAuthState(this.store.listDisabledCodexAuthCandidateNames(this.authRuntimeId()), this.resolveAuthDir());
5502
+ const state = await listCodexAuthState(this.store.listDisabledCodexAuthCandidateNames(this.authRuntimeId()), this.store.listCodexAuthCandidateStates(this.authRuntimeId()), this.resolveAuthDir());
5312
5503
  const snapshots = await this.readCodexAuthQuotaSnapshots();
5313
5504
  const candidateQuotaIdentities = await this.readCodexAuthCandidateQuotaIdentities(state.candidates);
5314
5505
  state.candidates.forEach((candidate) => {
@@ -5374,6 +5565,12 @@ export class BridgeSessionCore {
5374
5565
  return false;
5375
5566
  }
5376
5567
  }
5568
+ markCodexAuthCandidateNeedsRepair(candidateName) {
5569
+ this.store.setCodexAuthCandidateState(candidateName, 'needs_repair');
5570
+ }
5571
+ markCodexAuthCandidateActive(candidateName) {
5572
+ this.store.setCodexAuthCandidateState(candidateName, 'active');
5573
+ }
5377
5574
  async syncCodexAuthCandidate(candidateName) {
5378
5575
  try {
5379
5576
  await this.coordinator?.authCandidateUpdated?.(this.authRuntimeId(), candidateName);
@@ -5410,7 +5607,7 @@ export class BridgeSessionCore {
5410
5607
  try {
5411
5608
  for (const candidate of candidates) {
5412
5609
  const before = await readChatGptAuthMetadata(candidate.path);
5413
- if (!before || !chatGptAuthMetadataMatchesCandidateName(candidate.name, before)) {
5610
+ if (candidate.disabled || candidate.state === 'needs_repair' || !before || !chatGptAuthMetadataMatchesCandidateName(candidate.name, before)) {
5414
5611
  result.skipped.push(candidate.name);
5415
5612
  continue;
5416
5613
  }
@@ -5655,6 +5852,9 @@ export class BridgeSessionCore {
5655
5852
  if (!candidate) {
5656
5853
  return;
5657
5854
  }
5855
+ if (candidate.state === 'needs_repair') {
5856
+ return;
5857
+ }
5658
5858
  try {
5659
5859
  const metadata = await readChatGptAuthMetadata(candidate.path);
5660
5860
  if (!metadata || !chatGptAuthMetadataMatchesCandidateName(candidate.name, metadata)) {
@@ -8234,7 +8434,7 @@ function parseActiveTurnKey(key) {
8234
8434
  function codexAuthDir(explicitAuthDir = null) {
8235
8435
  return explicitAuthDir || process.env.CODEX_AUTH_DIR || path.join(os.homedir(), '.codex');
8236
8436
  }
8237
- async function listCodexAuthState(disabledNames = new Set(), explicitAuthDir = null) {
8437
+ async function listCodexAuthState(disabledNames = new Set(), candidateStates = new Map(), explicitAuthDir = null) {
8238
8438
  const authDir = codexAuthDir(explicitAuthDir);
8239
8439
  const authPath = path.join(authDir, 'auth.json');
8240
8440
  const currentTargetPath = await resolveCurrentAuthTarget(authDir, authPath);
@@ -8259,6 +8459,7 @@ async function listCodexAuthState(disabledNames = new Set(), explicitAuthDir = n
8259
8459
  path: candidatePath,
8260
8460
  isCurrent: currentTargetPath === candidatePath,
8261
8461
  disabled: disabledNames.has(entry.name),
8462
+ state: candidateStates.get(entry.name) ?? 'active',
8262
8463
  mtimeMs: stat.mtimeMs,
8263
8464
  credentialKind: 'invalid',
8264
8465
  credentialLastRefreshMs: null,
@@ -8370,7 +8571,7 @@ async function restoreCodexAuthTarget(authDir, authPath, targetPath, regularAuth
8370
8571
  });
8371
8572
  }
8372
8573
  async function switchCodexAuth(targetPath, explicitAuthDir = null) {
8373
- const state = await listCodexAuthState(new Set(), explicitAuthDir);
8574
+ const state = await listCodexAuthState(new Set(), new Map(), explicitAuthDir);
8374
8575
  const candidate = state.candidates.find(entry => entry.path === targetPath);
8375
8576
  if (!candidate) {
8376
8577
  throw new Error(`Auth candidate is no longer available: ${path.basename(targetPath)}`);
@@ -8446,7 +8647,7 @@ function filterCodexAuthCandidates(candidates, view) {
8446
8647
  .map((candidate, index) => ({ candidate, index }))
8447
8648
  .filter(({ candidate }) => !searchTerm || candidate.name.toLowerCase().includes(searchTerm))
8448
8649
  .filter(({ candidate }) => view.filter === 'all'
8449
- || (view.filter === 'enabled' && !candidate.disabled)
8650
+ || (view.filter === 'enabled' && !candidate.disabled && candidate.state !== 'needs_repair')
8450
8651
  || (view.filter === 'attention' && codexAuthCandidateNeedsAttention(candidate)));
8451
8652
  }
8452
8653
  function renderAuthListMessage(locale, state, botLabel = null, includeWeixinCopyPaste = false, view = null) {
@@ -8505,12 +8706,12 @@ function authChoiceKeyboard(locale, record) {
8505
8706
  const page = codexAuthListPage(record.candidates, record);
8506
8707
  const rows = page.visible.map(({ candidate, index }) => [
8507
8708
  {
8508
- text: clipButtonText(`${candidate.isCurrent ? '✅ ' : '🔐 '}${formatAuthQuotaButtonPrefix(candidate.quota)}|${formatCodexAuthCandidateDisplayName(candidate.name)}${candidate.disabled ? ' · off' : ''}`),
8509
- callback_data: `auth:${record.localId}:${index}`,
8709
+ text: clipButtonText(`${candidate.state === 'needs_repair' ? '? ' : candidate.isCurrent ? '✅ ' : '🔐 '}${formatAuthQuotaButtonPrefix(candidate.quota)}|${formatCodexAuthCandidateDisplayName(candidate.name)}${candidate.disabled ? ' · off' : ''}`),
8710
+ callback_data: candidate.state === 'needs_repair' ? `auth:${record.localId}:repair:${index}` : `auth:${record.localId}:${index}`,
8510
8711
  },
8511
8712
  {
8512
- text: t(locale, candidate.disabled ? 'button_auth_disable' : 'button_auth_enable'),
8513
- callback_data: `auth:${record.localId}:toggle:${index}`,
8713
+ text: candidate.state === 'needs_repair' ? '?' : t(locale, candidate.disabled ? 'button_auth_disable' : 'button_auth_enable'),
8714
+ callback_data: candidate.state === 'needs_repair' ? `auth:${record.localId}:repair:${index}` : `auth:${record.localId}:toggle:${index}`,
8514
8715
  },
8515
8716
  ]);
8516
8717
  const navigationRow = [];
@@ -8555,6 +8756,9 @@ function authFilterButton(locale, record, filter) {
8555
8756
  };
8556
8757
  }
8557
8758
  function codexAuthCandidateHealth(candidate) {
8759
+ if (candidate.state === 'needs_repair') {
8760
+ return 'needs_repair';
8761
+ }
8558
8762
  if (candidate.disabled) {
8559
8763
  return 'disabled';
8560
8764
  }
@@ -8589,6 +8793,7 @@ function codexAuthCandidateNeedsAttention(candidate) {
8589
8793
  || health === 'unknown'
8590
8794
  || health === 'low'
8591
8795
  || health === 'exhausted'
8796
+ || health === 'needs_repair'
8592
8797
  || health === 'invalid';
8593
8798
  }
8594
8799
  function formatCodexAuthCandidateStatus(locale, candidate) {
@@ -8613,6 +8818,13 @@ function authRefreshAllConfirmKeyboard(locale, record) {
8613
8818
  [{ text: t(locale, 'button_cancel'), callback_data: `auth:${record.localId}:refresh_all_cancel` }],
8614
8819
  ];
8615
8820
  }
8821
+ function authRepairKeyboard(locale, record, index) {
8822
+ return [
8823
+ [{ text: t(locale, 'button_auth_repair_login'), callback_data: `auth:${record.localId}:repair_login:${index}` }],
8824
+ [{ text: t(locale, 'button_auth_delete'), callback_data: `auth:${record.localId}:repair_delete:${index}` }],
8825
+ [{ text: t(locale, 'button_cancel'), callback_data: `auth:${record.localId}:repair_cancel:${index}` }],
8826
+ ];
8827
+ }
8616
8828
  function formatAuthRefreshAllResult(locale, result, mode = 'manual') {
8617
8829
  const lines = [t(locale, mode === 'proactive' ? 'auth_proactive_refresh_done' : 'auth_refresh_all_done', {
8618
8830
  refreshed: String(result.refreshed.length),
package/dist/i18n.d.ts CHANGED
@@ -162,6 +162,7 @@ declare const MESSAGES: {
162
162
  readonly auth_candidate_status_enabled: "enabled";
163
163
  readonly auth_candidate_status_disabled: "disabled";
164
164
  readonly auth_candidate_health_disabled: "disabled";
165
+ readonly auth_candidate_health_needs_repair: "needs login repair";
165
166
  readonly auth_candidate_health_ready: "ready";
166
167
  readonly auth_candidate_health_low: "low quota";
167
168
  readonly auth_candidate_health_exhausted: "quota exhausted";
@@ -174,6 +175,19 @@ declare const MESSAGES: {
174
175
  readonly auth_candidate_disabled: "Disabled auth candidate for auto-rotation: {value}";
175
176
  readonly auth_candidate_enabled_short: "Auth enabled";
176
177
  readonly auth_candidate_disabled_short: "Auth disabled";
178
+ readonly auth_candidate_needs_repair: "{value} needs login repair. Open /auth and use the ? action to repair or delete it.";
179
+ readonly auth_repair_actions_short: "Repair actions";
180
+ readonly auth_repair_actions_message: "{value} has been verified unusable and could not be recovered from sync. Choose an action.";
181
+ readonly auth_repair_login_preparing: "Preparing device login repair for {value}...";
182
+ readonly auth_repair_started: "Device login repair started for {value}.";
183
+ readonly auth_repair_done: "Auth candidate repaired: {value}";
184
+ readonly auth_repair_failed: "Auth repair failed for {value}: {error}";
185
+ readonly auth_repair_missing_file: "Auth repair finished but {value} was not written.";
186
+ readonly auth_repair_identity_mismatch: "Auth repair finished, but the logged-in account does not match {value}.";
187
+ readonly auth_repair_reverted: "Restored previous auth. The candidate still needs repair.";
188
+ readonly auth_candidate_deleted: "Deleted auth candidate: {value}";
189
+ readonly auth_candidate_deleted_short: "Auth deleted";
190
+ readonly auth_delete_current_restarted: "The deleted candidate was current, so Codex app-server was restarted without it.";
177
191
  readonly auth_no_candidates: "No auth candidates found. Expected files like auth.json_personal in the auth dir.";
178
192
  readonly auth_choice_expired: "This auth list is no longer active";
179
193
  readonly auth_choice_mismatch: "Auth choice does not match this message";
@@ -239,6 +253,8 @@ declare const MESSAGES: {
239
253
  readonly button_auth_reload: "🔄 Reload auth";
240
254
  readonly button_auth_safe_sync: "🧷 Safe sync";
241
255
  readonly button_auth_refresh_all_confirm: "⚠️ Accept risk & refresh";
256
+ readonly button_auth_repair_login: "🔑 Login repair";
257
+ readonly button_auth_delete: "🗑️ Delete";
242
258
  readonly button_auth_enable: "✅";
243
259
  readonly button_auth_disable: "⏸️";
244
260
  readonly button_auth_filter_all: "All";
@@ -818,6 +834,7 @@ declare const MESSAGES: {
818
834
  readonly auth_candidate_status_enabled: "启用";
819
835
  readonly auth_candidate_status_disabled: "禁用";
820
836
  readonly auth_candidate_health_disabled: "已禁用";
837
+ readonly auth_candidate_health_needs_repair: "需要登录修复";
821
838
  readonly auth_candidate_health_ready: "正常";
822
839
  readonly auth_candidate_health_low: "额度偏低";
823
840
  readonly auth_candidate_health_exhausted: "额度耗尽";
@@ -830,6 +847,19 @@ declare const MESSAGES: {
830
847
  readonly auth_candidate_disabled: "已禁用 auth 候选自动轮换:{value}";
831
848
  readonly auth_candidate_enabled_short: "auth 已启用";
832
849
  readonly auth_candidate_disabled_short: "auth 已禁用";
850
+ readonly auth_candidate_needs_repair: "{value} 需要登录修复。打开 /auth 后使用 ? 操作修复或删除。";
851
+ readonly auth_repair_actions_short: "修复操作";
852
+ readonly auth_repair_actions_message: "{value} 已确认不可用,并且同步恢复失败。请选择处理方式。";
853
+ readonly auth_repair_login_preparing: "正在准备为 {value} 进行设备登录修复...";
854
+ readonly auth_repair_started: "已开始为 {value} 进行设备登录修复。";
855
+ readonly auth_repair_done: "auth 候选已修复:{value}";
856
+ readonly auth_repair_failed: "{value} 修复失败:{error}";
857
+ readonly auth_repair_missing_file: "修复流程已结束,但没有写入 {value}。";
858
+ readonly auth_repair_identity_mismatch: "修复流程已结束,但登录账号与 {value} 不匹配。";
859
+ readonly auth_repair_reverted: "已恢复之前的 auth。该候选仍需要修复。";
860
+ readonly auth_candidate_deleted: "已删除 auth 候选:{value}";
861
+ readonly auth_candidate_deleted_short: "auth 已删除";
862
+ readonly auth_delete_current_restarted: "被删除的候选是当前 auth,已在删除后重启 Codex app-server。";
833
863
  readonly auth_no_candidates: "没有找到 auth 候选文件。请在 auth 目录中放置类似 auth.json_personal 的文件。";
834
864
  readonly auth_choice_expired: "这个 auth 列表已经不再有效";
835
865
  readonly auth_choice_mismatch: "这个 auth 选择按钮不属于当前消息";
@@ -895,6 +925,8 @@ declare const MESSAGES: {
895
925
  readonly button_auth_reload: "🔄 重载 auth";
896
926
  readonly button_auth_safe_sync: "🧷 安全同步";
897
927
  readonly button_auth_refresh_all_confirm: "⚠️ 接受风险并刷新";
928
+ readonly button_auth_repair_login: "🔑 登录修复";
929
+ readonly button_auth_delete: "🗑️ 删除";
898
930
  readonly button_auth_enable: "✅";
899
931
  readonly button_auth_disable: "⏸️";
900
932
  readonly button_auth_filter_all: "全部";
package/dist/i18n.js CHANGED
@@ -160,6 +160,7 @@ const MESSAGES = {
160
160
  auth_candidate_status_enabled: 'enabled',
161
161
  auth_candidate_status_disabled: 'disabled',
162
162
  auth_candidate_health_disabled: 'disabled',
163
+ auth_candidate_health_needs_repair: 'needs login repair',
163
164
  auth_candidate_health_ready: 'ready',
164
165
  auth_candidate_health_low: 'low quota',
165
166
  auth_candidate_health_exhausted: 'quota exhausted',
@@ -172,6 +173,19 @@ const MESSAGES = {
172
173
  auth_candidate_disabled: 'Disabled auth candidate for auto-rotation: {value}',
173
174
  auth_candidate_enabled_short: 'Auth enabled',
174
175
  auth_candidate_disabled_short: 'Auth disabled',
176
+ auth_candidate_needs_repair: '{value} needs login repair. Open /auth and use the ? action to repair or delete it.',
177
+ auth_repair_actions_short: 'Repair actions',
178
+ auth_repair_actions_message: '{value} has been verified unusable and could not be recovered from sync. Choose an action.',
179
+ auth_repair_login_preparing: 'Preparing device login repair for {value}...',
180
+ auth_repair_started: 'Device login repair started for {value}.',
181
+ auth_repair_done: 'Auth candidate repaired: {value}',
182
+ auth_repair_failed: 'Auth repair failed for {value}: {error}',
183
+ auth_repair_missing_file: 'Auth repair finished but {value} was not written.',
184
+ auth_repair_identity_mismatch: 'Auth repair finished, but the logged-in account does not match {value}.',
185
+ auth_repair_reverted: 'Restored previous auth. The candidate still needs repair.',
186
+ auth_candidate_deleted: 'Deleted auth candidate: {value}',
187
+ auth_candidate_deleted_short: 'Auth deleted',
188
+ auth_delete_current_restarted: 'The deleted candidate was current, so Codex app-server was restarted without it.',
175
189
  auth_no_candidates: 'No auth candidates found. Expected files like auth.json_personal in the auth dir.',
176
190
  auth_choice_expired: 'This auth list is no longer active',
177
191
  auth_choice_mismatch: 'Auth choice does not match this message',
@@ -237,6 +251,8 @@ const MESSAGES = {
237
251
  button_auth_reload: '🔄 Reload auth',
238
252
  button_auth_safe_sync: '🧷 Safe sync',
239
253
  button_auth_refresh_all_confirm: '⚠️ Accept risk & refresh',
254
+ button_auth_repair_login: '🔑 Login repair',
255
+ button_auth_delete: '🗑️ Delete',
240
256
  button_auth_enable: '✅',
241
257
  button_auth_disable: '⏸️',
242
258
  button_auth_filter_all: 'All',
@@ -816,6 +832,7 @@ const MESSAGES = {
816
832
  auth_candidate_status_enabled: '启用',
817
833
  auth_candidate_status_disabled: '禁用',
818
834
  auth_candidate_health_disabled: '已禁用',
835
+ auth_candidate_health_needs_repair: '需要登录修复',
819
836
  auth_candidate_health_ready: '正常',
820
837
  auth_candidate_health_low: '额度偏低',
821
838
  auth_candidate_health_exhausted: '额度耗尽',
@@ -828,6 +845,19 @@ const MESSAGES = {
828
845
  auth_candidate_disabled: '已禁用 auth 候选自动轮换:{value}',
829
846
  auth_candidate_enabled_short: 'auth 已启用',
830
847
  auth_candidate_disabled_short: 'auth 已禁用',
848
+ auth_candidate_needs_repair: '{value} 需要登录修复。打开 /auth 后使用 ? 操作修复或删除。',
849
+ auth_repair_actions_short: '修复操作',
850
+ auth_repair_actions_message: '{value} 已确认不可用,并且同步恢复失败。请选择处理方式。',
851
+ auth_repair_login_preparing: '正在准备为 {value} 进行设备登录修复...',
852
+ auth_repair_started: '已开始为 {value} 进行设备登录修复。',
853
+ auth_repair_done: 'auth 候选已修复:{value}',
854
+ auth_repair_failed: '{value} 修复失败:{error}',
855
+ auth_repair_missing_file: '修复流程已结束,但没有写入 {value}。',
856
+ auth_repair_identity_mismatch: '修复流程已结束,但登录账号与 {value} 不匹配。',
857
+ auth_repair_reverted: '已恢复之前的 auth。该候选仍需要修复。',
858
+ auth_candidate_deleted: '已删除 auth 候选:{value}',
859
+ auth_candidate_deleted_short: 'auth 已删除',
860
+ auth_delete_current_restarted: '被删除的候选是当前 auth,已在删除后重启 Codex app-server。',
831
861
  auth_no_candidates: '没有找到 auth 候选文件。请在 auth 目录中放置类似 auth.json_personal 的文件。',
832
862
  auth_choice_expired: '这个 auth 列表已经不再有效',
833
863
  auth_choice_mismatch: '这个 auth 选择按钮不属于当前消息',
@@ -893,6 +923,8 @@ const MESSAGES = {
893
923
  button_auth_reload: '🔄 重载 auth',
894
924
  button_auth_safe_sync: '🧷 安全同步',
895
925
  button_auth_refresh_all_confirm: '⚠️ 接受风险并刷新',
926
+ button_auth_repair_login: '🔑 登录修复',
927
+ button_auth_delete: '🗑️ 删除',
896
928
  button_auth_enable: '✅',
897
929
  button_auth_disable: '⏸️',
898
930
  button_auth_filter_all: '全部',
package/dist/main.js CHANGED
@@ -10,7 +10,7 @@ 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
11
  import { acquireProcessLock, LockHeldError } from './lock.js';
12
12
  import { readRuntimeStatus, writeRuntimeStatus } from './runtime.js';
13
- import { buildFoxclawSystemdUnitText, refreshFoxclawExecStartDropIns, removeFoxclawExecStartDropIns } from './systemd.js';
13
+ import { buildFoxclawSystemdUnitText, buildSystemdRestartHelperArgs, cgroupContainsSystemdUnit, refreshFoxclawExecStartDropIns, removeFoxclawExecStartDropIns, } from './systemd.js';
14
14
  import { createSelfUpdateRuntime, inferPnpmHomeFromEntryPoint, performSelfUpdate, readSelfUpdateStatus, writeSelfUpdateStatus, } from './update.js';
15
15
  const rawCommand = process.argv[2];
16
16
  const command = rawCommand || 'serve';
@@ -324,6 +324,7 @@ async function runServeCli() {
324
324
  canSelfUpdate: () => authSyncLocalIdle()
325
325
  && (authSync ? authSync.isIdle() : localAuthRefreshLease.isIdle()),
326
326
  authCandidateUpdated: (runtimeId, candidateName) => mirror.syncRuntimeCandidate(runtimeId, candidateName).then(() => undefined),
327
+ authCandidateDeleted: (_runtimeId, candidateName) => mirror.deleteCandidate(candidateName).then(() => undefined),
327
328
  recoverAuthCandidate: async (runtimeId, candidateName, options = {}) => {
328
329
  const local = await mirror.recoverRuntimeCandidate(runtimeId, candidateName);
329
330
  if (local)
@@ -492,6 +493,7 @@ async function runServeCli() {
492
493
  canSelfUpdate: () => singleAuthSyncLocalIdle()
493
494
  && (singleAuthSync ? singleAuthSync.isIdle() : singleLocalAuthRefreshLease.isIdle()),
494
495
  authCandidateUpdated: (runtimeId, candidateName) => singleMirror?.syncRuntimeCandidate(runtimeId, candidateName).then(() => undefined) ?? Promise.resolve(),
496
+ authCandidateDeleted: (_runtimeId, candidateName) => singleMirror?.deleteCandidate(candidateName).then(() => undefined) ?? Promise.resolve(),
495
497
  recoverAuthCandidate: async (runtimeId, candidateName, options = {}) => {
496
498
  const local = await singleMirror?.recoverRuntimeCandidate(runtimeId, candidateName) ?? null;
497
499
  if (local)
@@ -1305,13 +1307,46 @@ function installSystemd() {
1305
1307
  ensureSystemdUserLingerEnabled();
1306
1308
  spawnChecked('systemctl', ['--user', 'daemon-reload']);
1307
1309
  spawnChecked('systemctl', ['--user', 'enable', unitName]);
1310
+ restartSystemdUnit(unitName);
1311
+ console.log(`Installed ${unitPath}`);
1312
+ console.log(`Status: systemctl --user status ${unitName}`);
1313
+ console.log(`Logs: journalctl --user -u ${unitName} -f`);
1314
+ }
1315
+ function restartSystemdUnit(unitName) {
1316
+ if (isCurrentProcessInSystemdUnit(unitName)) {
1317
+ const systemdRun = resolveCommand('systemd-run');
1318
+ if (systemdRun) {
1319
+ const helperUnitName = `foxclaw-restart-${process.pid}-${Date.now()}`;
1320
+ const result = spawnSync(systemdRun, buildSystemdRestartHelperArgs({
1321
+ unitName,
1322
+ helperUnitName,
1323
+ delaySeconds: 1,
1324
+ }), { stdio: 'inherit' });
1325
+ if (result.status === 0) {
1326
+ console.log(`[OK] scheduled ${unitName} restart via transient systemd helper: ${helperUnitName}`);
1327
+ return;
1328
+ }
1329
+ console.log('[WARN] transient systemd restart helper failed; falling back to direct restart.');
1330
+ }
1331
+ else {
1332
+ console.log('[WARN] systemd-run not found; falling back to direct restart.');
1333
+ }
1334
+ }
1308
1335
  const restarted = spawnSync('systemctl', ['--user', 'restart', unitName], { stdio: 'inherit' });
1309
1336
  if (restarted.status !== 0) {
1310
1337
  spawnChecked('systemctl', ['--user', 'start', unitName]);
1311
1338
  }
1312
- console.log(`Installed ${unitPath}`);
1313
- console.log(`Status: systemctl --user status ${unitName}`);
1314
- console.log(`Logs: journalctl --user -u ${unitName} -f`);
1339
+ }
1340
+ function isCurrentProcessInSystemdUnit(unitName) {
1341
+ if (process.platform !== 'linux') {
1342
+ return false;
1343
+ }
1344
+ try {
1345
+ return cgroupContainsSystemdUnit(fs.readFileSync('/proc/self/cgroup', 'utf8'), unitName);
1346
+ }
1347
+ catch {
1348
+ return false;
1349
+ }
1315
1350
  }
1316
1351
  function ensureSystemdUserLingerEnabled() {
1317
1352
  if (process.platform !== 'linux') {
@@ -37,6 +37,7 @@ export interface CodexAuthQuotaSnapshotRecord {
37
37
  secondaryRemainingPercent: number | null;
38
38
  updatedAt: number;
39
39
  }
40
+ export type CodexAuthCandidateState = 'active' | 'needs_repair';
40
41
  export declare class BridgeStore {
41
42
  private db;
42
43
  constructor(dbPath: string);
@@ -117,7 +118,10 @@ export declare class BridgeStore {
117
118
  getWeixinContextToken(scopeId: string): string | null;
118
119
  setWeixinContextToken(scopeId: string, contextToken: string): void;
119
120
  listDisabledCodexAuthCandidateNames(runtimeId?: string): Set<string>;
121
+ listCodexAuthCandidateStates(runtimeId?: string): Map<string, CodexAuthCandidateState>;
120
122
  setCodexAuthCandidateDisabled(name: string, disabled: boolean, runtimeId?: string): void;
123
+ setCodexAuthCandidateState(name: string, state: CodexAuthCandidateState, runtimeId?: string): void;
124
+ deleteCodexAuthCandidate(name: string): void;
121
125
  setCodexAuthQuotaSnapshot(runtimeId: string, candidateName: string, accountId: string, quotaIdentityId: string, snapshot: Pick<CodexAuthQuotaSnapshotRecord, 'capturedAtMs' | 'planType' | 'primaryWindowDurationMins' | 'primaryRemainingPercent' | 'secondaryWindowDurationMins' | 'secondaryRemainingPercent'>): void;
122
126
  listCodexAuthQuotaSnapshots(quotaIdentityIds: string[]): CodexAuthQuotaSnapshotRecord[];
123
127
  private ensureColumn;
@@ -169,12 +169,14 @@ export class BridgeStore {
169
169
  CREATE TABLE IF NOT EXISTS codex_auth_candidates (
170
170
  name TEXT PRIMARY KEY,
171
171
  disabled INTEGER NOT NULL DEFAULT 0,
172
+ state TEXT NOT NULL DEFAULT 'active',
172
173
  updated_at INTEGER NOT NULL
173
174
  );
174
175
  CREATE TABLE IF NOT EXISTS codex_auth_candidate_runtime (
175
176
  runtime_id TEXT NOT NULL,
176
177
  name TEXT NOT NULL,
177
178
  disabled INTEGER NOT NULL DEFAULT 0,
179
+ state TEXT NOT NULL DEFAULT 'active',
178
180
  updated_at INTEGER NOT NULL,
179
181
  PRIMARY KEY (runtime_id, name)
180
182
  );
@@ -207,6 +209,8 @@ export class BridgeStore {
207
209
  this.ensureColumn('pending_approvals', 'payload_json', 'TEXT');
208
210
  this.ensureColumn('pending_user_inputs', 'status', "TEXT NOT NULL DEFAULT 'pending'");
209
211
  this.ensureColumn('pending_user_inputs', 'submitted_at', 'INTEGER');
212
+ this.ensureColumn('codex_auth_candidates', 'state', "TEXT NOT NULL DEFAULT 'active'");
213
+ this.ensureColumn('codex_auth_candidate_runtime', 'state', "TEXT NOT NULL DEFAULT 'active'");
210
214
  this.ensureColumn('codex_auth_quota_snapshots', 'plan_type', 'TEXT');
211
215
  this.ensureColumn('codex_auth_quota_snapshots', 'primary_window_duration_mins', 'REAL');
212
216
  this.ensureColumn('codex_auth_quota_snapshots', 'secondary_window_duration_mins', 'REAL');
@@ -835,21 +839,60 @@ export class BridgeStore {
835
839
  const rows = this.db.prepare('SELECT name FROM codex_auth_candidates WHERE disabled = 1').all();
836
840
  return new Set(rows.map(row => String(row.name)));
837
841
  }
842
+ listCodexAuthCandidateStates(runtimeId = 'default') {
843
+ const states = new Map();
844
+ const globalRows = this.db.prepare('SELECT name, state FROM codex_auth_candidates').all();
845
+ for (const row of globalRows) {
846
+ states.set(String(row.name), normalizeCodexAuthCandidateState(row.state));
847
+ }
848
+ if (runtimeId !== 'default') {
849
+ const runtimeRows = this.db.prepare('SELECT name, state FROM codex_auth_candidate_runtime WHERE runtime_id = ?').all(runtimeId);
850
+ for (const row of runtimeRows) {
851
+ states.set(String(row.name), normalizeCodexAuthCandidateState(row.state));
852
+ }
853
+ }
854
+ return states;
855
+ }
838
856
  setCodexAuthCandidateDisabled(name, disabled, runtimeId = 'default') {
839
857
  if (runtimeId !== 'default') {
840
858
  this.db.prepare(`
841
- INSERT INTO codex_auth_candidate_runtime (runtime_id, name, disabled, updated_at)
842
- VALUES (?, ?, ?, ?)
859
+ INSERT INTO codex_auth_candidate_runtime (runtime_id, name, disabled, state, updated_at)
860
+ VALUES (?, ?, ?, 'active', ?)
843
861
  ON CONFLICT(runtime_id, name) DO UPDATE SET disabled = excluded.disabled, updated_at = excluded.updated_at
844
862
  `).run(runtimeId, name, disabled ? 1 : 0, Date.now());
845
863
  return;
846
864
  }
847
865
  this.db.prepare(`
848
- INSERT INTO codex_auth_candidates (name, disabled, updated_at)
849
- VALUES (?, ?, ?)
866
+ INSERT INTO codex_auth_candidates (name, disabled, state, updated_at)
867
+ VALUES (?, ?, 'active', ?)
850
868
  ON CONFLICT(name) DO UPDATE SET disabled = excluded.disabled, updated_at = excluded.updated_at
851
869
  `).run(name, disabled ? 1 : 0, Date.now());
852
870
  }
871
+ setCodexAuthCandidateState(name, state, runtimeId = 'default') {
872
+ if (runtimeId !== 'default') {
873
+ this.db.prepare(`
874
+ INSERT INTO codex_auth_candidate_runtime (runtime_id, name, disabled, state, updated_at)
875
+ VALUES (?, ?, 0, ?, ?)
876
+ ON CONFLICT(runtime_id, name) DO UPDATE SET state = excluded.state, updated_at = excluded.updated_at
877
+ `).run(runtimeId, name, state, Date.now());
878
+ return;
879
+ }
880
+ this.db.prepare(`
881
+ INSERT INTO codex_auth_candidates (name, disabled, state, updated_at)
882
+ VALUES (?, 0, ?, ?)
883
+ ON CONFLICT(name) DO UPDATE SET state = excluded.state, updated_at = excluded.updated_at
884
+ `).run(name, state, Date.now());
885
+ this.db.prepare(`
886
+ UPDATE codex_auth_candidate_runtime
887
+ SET state = ?, updated_at = ?
888
+ WHERE name = ?
889
+ `).run(state, Date.now(), name);
890
+ }
891
+ deleteCodexAuthCandidate(name) {
892
+ this.db.prepare('DELETE FROM codex_auth_candidates WHERE name = ?').run(name);
893
+ this.db.prepare('DELETE FROM codex_auth_candidate_runtime WHERE name = ?').run(name);
894
+ this.db.prepare('DELETE FROM codex_auth_quota_snapshots WHERE candidate_name = ?').run(name);
895
+ }
853
896
  setCodexAuthQuotaSnapshot(runtimeId, candidateName, accountId, quotaIdentityId, snapshot) {
854
897
  this.db.prepare(`
855
898
  INSERT INTO codex_auth_quota_snapshots (
@@ -932,6 +975,9 @@ function nullableNumber(value) {
932
975
  function nullableString(value) {
933
976
  return typeof value === 'string' && value.trim() ? value : null;
934
977
  }
978
+ function normalizeCodexAuthCandidateState(value) {
979
+ return value === 'needs_repair' ? 'needs_repair' : 'active';
980
+ }
935
981
  function normalizeCollaborationMode(value) {
936
982
  return value === 'default' || value === 'plan' ? value : null;
937
983
  }
package/dist/systemd.d.ts CHANGED
@@ -11,7 +11,14 @@ export interface FoxclawSystemdUnitTextOptions {
11
11
  pathValue: string;
12
12
  execStart: string;
13
13
  }
14
+ export interface SystemdRestartHelperOptions {
15
+ unitName: string;
16
+ helperUnitName: string;
17
+ delaySeconds: number;
18
+ }
14
19
  export declare function buildFoxclawSystemdUnitText(options: FoxclawSystemdUnitTextOptions): string;
20
+ export declare function cgroupContainsSystemdUnit(cgroupText: string, unitName: string): boolean;
21
+ export declare function buildSystemdRestartHelperArgs(options: SystemdRestartHelperOptions): string[];
15
22
  export declare function refreshFoxclawExecStartDropIns(userSystemdDir: string, unitName: string, escapedEntryPoint: string): SystemdDropInUpdate[];
16
23
  export declare function removeFoxclawExecStartDropIns(userSystemdDir: string, unitName: string): SystemdDropInUpdate[];
17
24
  export declare function refreshFoxclawExecStartText(text: string, escapedEntryPoint: string): {
package/dist/systemd.js CHANGED
@@ -29,6 +29,23 @@ KillMode=control-group
29
29
  WantedBy=default.target
30
30
  `;
31
31
  }
32
+ export function cgroupContainsSystemdUnit(cgroupText, unitName) {
33
+ const escapedUnitName = unitName.replace(/-/g, '\\x2d');
34
+ return cgroupText
35
+ .split(/\r?\n/)
36
+ .some((line) => line.includes(`/${unitName}`) || line.includes(`/${escapedUnitName}`));
37
+ }
38
+ export function buildSystemdRestartHelperArgs(options) {
39
+ return [
40
+ '--user',
41
+ '--collect',
42
+ `--unit=${options.helperUnitName}`,
43
+ '--description=Restart FoxClaw service outside foxclaw.service cgroup',
44
+ '/bin/sh',
45
+ '-lc',
46
+ `sleep ${Math.max(0, options.delaySeconds)}; exec systemctl --user restart ${options.unitName}`,
47
+ ];
48
+ }
32
49
  export function refreshFoxclawExecStartDropIns(userSystemdDir, unitName, escapedEntryPoint) {
33
50
  const dropInDir = path.join(userSystemdDir, `${unitName}.d`);
34
51
  let names;
@@ -415,7 +415,9 @@ Quota remaining: window:percent|auth
415
415
  [🔄 Reload auth]
416
416
  ```
417
417
 
418
- The right-side `✅` / `⏸️` button controls whether the candidate participates in auto-rotation. 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. Health summaries distinguish ready, low quota, quota exhausted, quota unknown, not recently refreshed, API key, and invalid auth file states.
418
+ The right-side `✅` / `⏸️` button controls whether the candidate participates in auto-rotation. 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. Health summaries distinguish ready, low quota, quota exhausted, quota unknown, not recently refreshed, API key, invalid auth file, and needs login repair states.
419
+
420
+ When an auth candidate has already failed while in use and FoxClaw cannot recover a newer same-account credential from local mirror or cross-node sync, it is marked as `needs login repair`. These candidates are skipped by auto-rotation and proactive refresh, and are hidden from the `Enabled` filter. Their row shows a `?` action. Tapping it opens two choices: Login repair starts device-code login with that candidate selected; Delete removes the candidate from canonical storage and all local bot runtimes, and clears cached quota for it.
419
421
 
420
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.
421
423
 
@@ -415,7 +415,9 @@ Candidates: 2
415
415
  [🔄 Reload auth]
416
416
  ```
417
417
 
418
- 右侧 `✅` / `⏸️` 表示当前是否参与自动轮转。点一下会切换启用/禁用,列表刷新后图标会随状态变化。点击候选会切换 auth、重启对应 runtime,并在原消息上刷新面板且保留按钮,因此可以立即连续切换。`--` 表示该候选还没有额度历史快照。健康摘要会区分正常、额度偏低、额度耗尽、额度未知、长期未刷新、API key 和无效 auth 文件。
418
+ 右侧 `✅` / `⏸️` 表示当前是否参与自动轮转。点一下会切换启用/禁用,列表刷新后图标会随状态变化。点击候选会切换 auth、重启对应 runtime,并在原消息上刷新面板且保留按钮,因此可以立即连续切换。`--` 表示该候选还没有额度历史快照。健康摘要会区分正常、额度偏低、额度耗尽、额度未知、长期未刷新、API key、无效 auth 文件和需要登录修复。
419
+
420
+ 当某个候选在实际使用中已经失败,并且 FoxClaw 无法从本机 mirror 或跨节点同步恢复同账号较新凭据时,会标为“需要登录修复”。这类候选不会参与自动轮转和后台主动刷新,也不会出现在“已启用”筛选里。它的按钮会显示 `?`。点击后有两个选择:`登录修复` 会在选中该候选的状态下启动设备码登录;`删除` 会从 canonical 和所有本机 bot runtime 中删除这个候选,并清理它的额度缓存。
419
421
 
420
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 并显示摘要。
421
423
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.5.13",
3
+ "version": "0.5.15",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",