@foxden-app/foxclaw 0.5.1 → 0.5.3

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,32 @@
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.3 - 2026-06-04
6
+
7
+ ### 中文
8
+ - 修复 0.5.2 中 `/update` 完成回报丢失的问题:Linux 自升级子进程现在通过独立的 user systemd transient service 运行,不再留在 `foxclaw.service` control group 里,因此 `KillMode=control-group` 重启服务时不会提前杀掉 updater。
9
+ - 保留 `KillMode=control-group` 的 app-server 清理能力,同时让升级进程能在服务重启后继续写入完成状态,由新服务启动后的轮询发送 Telegram 成功/失败回报。
10
+
11
+ ### English
12
+ - Fixed missing `/update` completion reports in 0.5.2: on Linux the self-update worker now runs in a separate user systemd transient service instead of the `foxclaw.service` control group, so `KillMode=control-group` no longer kills the updater during service restart.
13
+ - Kept `KillMode=control-group` app-server cleanup while allowing the updater to write its final status after restart; the newly started service polls that status and sends the Telegram success/failure report.
14
+
15
+ ## 0.5.2 - 2026-06-04
16
+
17
+ ### 中文
18
+ - Linux systemd unit 改为停止整个 control group,避免升级或重启 FoxClaw 后旧的 `codex app-server --listen` 子进程残留。
19
+ - 启动时自动修复 `auth.json -> .auth-sync-validate-*` 临时验证 symlink 残留,恢复到 mirror 状态候选或同目录最近修改且可解析的真实 `auth.json_*` 候选,并清理临时文件。
20
+ - 跨节点 auth 同步把单候选验证/导入失败记录为“候选失败”,不再污染全局 `lastError`;`/status` 和 `/auth sync status` 会分开展示同步系统错误与候选失败。
21
+ - 手动 `/auth` 切换和 `/auth reload` 只做同节点本地镜像恢复,不再主动向跨节点 peer 查询;只有自动 auth 故障恢复才会跨节点 pull。
22
+ - auth 恢复超时通知补充 request id、候选名、peer、等待时长,并标明等待期间可达但本请求超时的 peer。
23
+
24
+ ### English
25
+ - Changed the Linux systemd unit to stop the whole service control group so FoxClaw upgrades or restarts no longer leave old `codex app-server --listen` children behind.
26
+ - Added startup recovery for `auth.json -> .auth-sync-validate-*` validation symlink leftovers, restoring to the mirror-status candidate or the newest parseable real `auth.json_*` candidate in the same directory and removing stale temp files.
27
+ - Cross-node auth sync now records per-candidate validation/import failures as candidate failures instead of global `lastError`; `/status` and `/auth sync status` show sync-system errors separately from candidate failures.
28
+ - Manual `/auth` switching and `/auth reload` now recover from same-node local mirrors only and no longer query cross-node peers; cross-node pull is reserved for automatic auth-failure recovery.
29
+ - Auth recovery timeout notifications now include request id, candidate name, peers, wait duration, and whether a peer was reachable during the timed-out request.
30
+
5
31
  ## 0.5.1 - 2026-06-04
6
32
 
7
33
  ### 中文
@@ -29,8 +29,18 @@ export interface AuthSyncStatus {
29
29
  lastPullAt: string | null;
30
30
  lastPullCandidate: string | null;
31
31
  lastError: string | null;
32
+ candidateFailures: AuthSyncCandidateFailure[];
32
33
  activeLeaseId: string | null;
33
34
  }
35
+ export interface AuthSyncCandidateFailure {
36
+ candidateName: string;
37
+ reason: string;
38
+ sourceNodeId: string | null;
39
+ sourceLabel: string | null;
40
+ peer: string | null;
41
+ mode: AuthSyncRemoteImportMode;
42
+ updatedAt: string;
43
+ }
34
44
  export interface AuthSyncValidationResult {
35
45
  ok: boolean;
36
46
  reason?: string | null;
@@ -99,7 +109,9 @@ export type AuthSyncNotification = {
99
109
  } | {
100
110
  kind: 'recovery_started';
101
111
  candidateName: string;
112
+ requestId: string;
102
113
  peers: string[];
114
+ timeoutMs: number;
103
115
  } | {
104
116
  kind: 'recovery_peer_empty';
105
117
  candidateName: string;
@@ -113,8 +125,11 @@ export type AuthSyncNotification = {
113
125
  } | {
114
126
  kind: 'recovery_failed';
115
127
  candidateName: string;
128
+ requestId?: string;
116
129
  peers: string[];
117
130
  reason: string;
131
+ waitMs?: number;
132
+ peerReachability?: AuthSyncPeerReachability[];
118
133
  } | {
119
134
  kind: 'pull_request_received';
120
135
  candidateName: string;
@@ -141,6 +156,11 @@ export interface AuthSyncImportCallbacks {
141
156
  isIdle: () => boolean;
142
157
  notify?: (event: AuthSyncNotification) => Promise<void>;
143
158
  }
159
+ export interface AuthSyncPeerReachability {
160
+ peer: string;
161
+ reachableDuringRequest: boolean;
162
+ lastReceivedAt: string | null;
163
+ }
144
164
  export interface AuthSyncTransport {
145
165
  send: (peer: string, envelope: string) => Promise<void>;
146
166
  }
@@ -168,6 +188,7 @@ export declare class CrossNodeAuthSync {
168
188
  private readonly pendingLeases;
169
189
  private readonly pendingTests;
170
190
  private seenNonces;
191
+ private lastPeerActivityAt;
171
192
  private timer;
172
193
  private importProcessorActive;
173
194
  private activeRemoteLease;
@@ -216,6 +237,10 @@ export declare class CrossNodeAuthSync {
216
237
  private expireLeases;
217
238
  private recordError;
218
239
  private rejectImport;
240
+ private recordCandidateFailure;
241
+ private clearCandidateFailure;
242
+ private notePeerActivity;
243
+ private describePeerReachability;
219
244
  private notify;
220
245
  private notifyError;
221
246
  private writeState;
@@ -24,6 +24,7 @@ export class CrossNodeAuthSync {
24
24
  pendingLeases = new Map();
25
25
  pendingTests = new Map();
26
26
  seenNonces = new Map();
27
+ lastPeerActivityAt = new Map();
27
28
  timer = null;
28
29
  importProcessorActive = false;
29
30
  activeRemoteLease = null;
@@ -37,6 +38,7 @@ export class CrossNodeAuthSync {
37
38
  lastPullAt: null,
38
39
  lastPullCandidate: null,
39
40
  lastError: null,
41
+ lastCandidateFailures: {},
40
42
  };
41
43
  constructor(config, logger, transport, callbacks) {
42
44
  this.config = config;
@@ -64,6 +66,7 @@ export class CrossNodeAuthSync {
64
66
  lastPullAt: stored.lastPullAt ?? null,
65
67
  lastPullCandidate: stored.lastPullCandidate ?? null,
66
68
  lastError: stored.lastError ?? null,
69
+ lastCandidateFailures: normalizeCandidateFailures(stored.lastCandidateFailures ?? {}),
67
70
  };
68
71
  await fs.mkdir(this.config.tempDir, { recursive: true, mode: 0o700 });
69
72
  await this.writeState();
@@ -103,6 +106,8 @@ export class CrossNodeAuthSync {
103
106
  lastPullAt: this.state.lastPullAt,
104
107
  lastPullCandidate: this.state.lastPullCandidate,
105
108
  lastError: this.state.lastError,
109
+ candidateFailures: Object.values(this.state.lastCandidateFailures)
110
+ .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)),
106
111
  activeLeaseId: this.activeLocalLease?.leaseId ?? this.activeRemoteLease?.leaseId ?? null,
107
112
  };
108
113
  }
@@ -188,30 +193,46 @@ export class CrossNodeAuthSync {
188
193
  return false;
189
194
  const requestId = crypto.randomUUID();
190
195
  const peers = [...this.peers];
191
- this.notify({ kind: 'recovery_started', candidateName, peers });
196
+ const startedAt = Date.now();
197
+ this.notify({ kind: 'recovery_started', candidateName, requestId, peers, timeoutMs: PULL_TIMEOUT_MS });
192
198
  const result = await new Promise((resolve) => {
193
199
  const timer = setTimeout(() => {
194
200
  const pending = this.pendingPulls.get(requestId);
195
201
  if (pending) {
196
202
  pending.finished = true;
197
203
  this.pendingPulls.delete(requestId);
204
+ const waitMs = Date.now() - pending.startedAt;
205
+ const peerReachability = this.describePeerReachability(pending.peers, pending.startedAt);
206
+ const reason = formatPullTimeoutReason(pending, waitMs, peerReachability);
207
+ this.logger.warn('auth.sync.pull_timeout', {
208
+ requestId,
209
+ candidateName,
210
+ peers: pending.peers,
211
+ waitMs,
212
+ peerReachability,
213
+ });
198
214
  this.notify({
199
215
  kind: 'recovery_failed',
200
216
  candidateName,
217
+ requestId,
201
218
  peers,
202
- reason: `timed out waiting for ${this.peers.length} auth sync peer response(s)`,
219
+ reason,
220
+ waitMs,
221
+ peerReachability,
203
222
  });
204
223
  resolve(false);
205
224
  }
206
225
  }, PULL_TIMEOUT_MS);
207
226
  timer.unref();
208
227
  this.pendingPulls.set(requestId, {
228
+ requestId,
209
229
  candidateName,
210
230
  peers,
211
231
  emptyReplies: new Map(),
212
232
  resolve,
213
233
  timer,
214
234
  finished: false,
235
+ startedAt,
215
236
  });
216
237
  void this.sendToAll({
217
238
  kind: 'pull.request',
@@ -223,11 +244,15 @@ export class CrossNodeAuthSync {
223
244
  clearTimeout(timer);
224
245
  this.pendingPulls.delete(requestId);
225
246
  this.recordError(`pull request failed: ${formatError(error)}`, false);
247
+ const waitMs = Date.now() - startedAt;
226
248
  this.notify({
227
249
  kind: 'recovery_failed',
228
250
  candidateName,
251
+ requestId,
229
252
  peers: [...this.peers],
230
- reason: formatError(error),
253
+ reason: `pull request send failed; requestId=${requestId}; candidate=${candidateName}; peers=${this.peers.join(', ') || 'none'}; waitMs=${waitMs}; error=${formatError(error)}`,
254
+ waitMs,
255
+ peerReachability: this.describePeerReachability(peers, startedAt),
231
256
  });
232
257
  resolve(false);
233
258
  });
@@ -340,6 +365,7 @@ export class CrossNodeAuthSync {
340
365
  this.seenNonces.set(nonceKey, Date.now());
341
366
  this.state.lastReceivedAt = new Date().toISOString();
342
367
  await this.writeState();
368
+ this.notePeerActivity(normalizePeerIdentity(peer));
343
369
  await this.handleMessage(opened.message, opened.sender, peer);
344
370
  return true;
345
371
  }
@@ -616,6 +642,7 @@ export class CrossNodeAuthSync {
616
642
  if (!result.ok) {
617
643
  return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote candidate import failed for ${bundle.candidateName}: ${result.reason ?? 'unknown'}`);
618
644
  }
645
+ const clearedFailure = this.clearCandidateFailure(bundle.candidateName);
619
646
  if (result.imported) {
620
647
  this.state.lastImportedAt = new Date().toISOString();
621
648
  this.state.lastImportCandidate = bundle.candidateName;
@@ -641,6 +668,9 @@ export class CrossNodeAuthSync {
641
668
  mode,
642
669
  reason: result.reason ?? 'local candidate did not need an update',
643
670
  });
671
+ if (clearedFailure) {
672
+ await this.writeState();
673
+ }
644
674
  }
645
675
  return { ok: true, imported: result.imported, reason: result.reason ?? null };
646
676
  }
@@ -767,8 +797,8 @@ export class CrossNodeAuthSync {
767
797
  this.notifyError(message);
768
798
  }
769
799
  }
770
- rejectImport(bundle, sourceNodeId, sourceLabel, fromPeer, mode, reason) {
771
- this.recordError(reason, false);
800
+ async rejectImport(bundle, sourceNodeId, sourceLabel, fromPeer, mode, reason) {
801
+ await this.recordCandidateFailure(bundle, sourceNodeId, sourceLabel, fromPeer, mode, reason);
772
802
  this.notify({
773
803
  kind: 'remote_import_failed',
774
804
  candidateName: bundle.candidateName,
@@ -780,6 +810,51 @@ export class CrossNodeAuthSync {
780
810
  });
781
811
  return { ok: false, imported: false, reason };
782
812
  }
813
+ async recordCandidateFailure(bundle, sourceNodeId, sourceLabel, fromPeer, mode, reason) {
814
+ const candidateName = typeof bundle.candidateName === 'string' && bundle.candidateName.trim()
815
+ ? bundle.candidateName
816
+ : 'invalid-bundle';
817
+ this.state.lastCandidateFailures[candidateName] = {
818
+ candidateName,
819
+ reason,
820
+ sourceNodeId,
821
+ sourceLabel,
822
+ peer: fromPeer,
823
+ mode,
824
+ updatedAt: new Date().toISOString(),
825
+ };
826
+ this.state.lastCandidateFailures = pruneCandidateFailures(this.state.lastCandidateFailures);
827
+ this.logger.warn('auth.sync.candidate_failed', {
828
+ candidateName,
829
+ sourceNodeId,
830
+ sourceLabel,
831
+ peer: fromPeer,
832
+ mode,
833
+ reason,
834
+ });
835
+ await this.writeState();
836
+ }
837
+ clearCandidateFailure(candidateName) {
838
+ if (!this.state.lastCandidateFailures[candidateName]) {
839
+ return false;
840
+ }
841
+ delete this.state.lastCandidateFailures[candidateName];
842
+ return true;
843
+ }
844
+ notePeerActivity(peer) {
845
+ const matchedPeer = this.matchConfiguredPeer(peer) ?? peer;
846
+ this.lastPeerActivityAt.set(matchedPeer, Date.now());
847
+ }
848
+ describePeerReachability(peers, sinceMs) {
849
+ return peers.map((peer) => {
850
+ const lastActivityAt = this.lastPeerActivityAt.get(peer) ?? null;
851
+ return {
852
+ peer,
853
+ reachableDuringRequest: lastActivityAt !== null && lastActivityAt >= sinceMs,
854
+ lastReceivedAt: lastActivityAt === null ? null : new Date(lastActivityAt).toISOString(),
855
+ };
856
+ });
857
+ }
783
858
  notify(event) {
784
859
  if (!this.callbacks.notify)
785
860
  return;
@@ -886,6 +961,46 @@ function hashAccountId(accountId) {
886
961
  function sha256(value) {
887
962
  return crypto.createHash('sha256').update(value).digest('hex');
888
963
  }
964
+ function normalizeCandidateFailures(raw) {
965
+ const failures = {};
966
+ for (const [key, value] of Object.entries(raw)) {
967
+ const candidateName = typeof value.candidateName === 'string' && value.candidateName.trim()
968
+ ? value.candidateName
969
+ : key;
970
+ const reason = typeof value.reason === 'string' ? value.reason : '';
971
+ const mode = value.mode === 'pull' ? 'pull' : 'push';
972
+ const updatedAt = typeof value.updatedAt === 'string' && Number.isFinite(Date.parse(value.updatedAt))
973
+ ? value.updatedAt
974
+ : new Date().toISOString();
975
+ if (!candidateName || !reason) {
976
+ continue;
977
+ }
978
+ failures[candidateName] = {
979
+ candidateName,
980
+ reason,
981
+ sourceNodeId: typeof value.sourceNodeId === 'string' ? value.sourceNodeId : null,
982
+ sourceLabel: typeof value.sourceLabel === 'string' ? value.sourceLabel : null,
983
+ peer: typeof value.peer === 'string' ? value.peer : null,
984
+ mode,
985
+ updatedAt,
986
+ };
987
+ }
988
+ return pruneCandidateFailures(failures);
989
+ }
990
+ function pruneCandidateFailures(failures) {
991
+ return Object.fromEntries(Object.entries(failures)
992
+ .sort(([, left], [, right]) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt))
993
+ .slice(0, 20));
994
+ }
995
+ function formatPullTimeoutReason(pending, waitMs, peerReachability) {
996
+ const reachable = peerReachability
997
+ .filter((entry) => entry.reachableDuringRequest)
998
+ .map((entry) => entry.peer);
999
+ const reachableSuffix = reachable.length > 0
1000
+ ? `; peer reachable but this request timed out: ${reachable.join(', ')}`
1001
+ : '';
1002
+ return `timed out waiting for ${pending.peers.length} auth sync peer response(s); requestId=${pending.requestId}; candidate=${pending.candidateName}; peers=${pending.peers.join(', ') || 'none'}; waitMs=${waitMs}${reachableSuffix}`;
1003
+ }
889
1004
  function normalizeConfiguredPeer(peer) {
890
1005
  const value = peer.trim();
891
1006
  if (!value)
@@ -93,6 +93,8 @@ export declare class AuthCandidateMirror {
93
93
  private resolveCanonicalCurrentCandidate;
94
94
  private findNewestRecord;
95
95
  private runtimeLabel;
96
+ private recoverInterruptedValidationSymlink;
97
+ private selectValidationRecoveryCandidate;
96
98
  }
97
99
  export declare function isAuthCandidateName(name: string): boolean;
98
100
  export declare function readChatGptAuthRecord(filePath: string): Promise<ChatGptAuthRecord | null>;
@@ -23,7 +23,12 @@ export class AuthCandidateMirror {
23
23
  async initialize() {
24
24
  this.lastStatus = await readMirrorStatus(this.statusPath);
25
25
  await fs.mkdir(this.canonicalDir, { recursive: true, mode: 0o700 });
26
+ await this.recoverInterruptedValidationSymlink(this.canonicalDir);
26
27
  await this.ensureCanonicalDefaultCandidate();
28
+ for (const runtime of this.runtimes) {
29
+ await fs.mkdir(runtime.authDir, { recursive: true, mode: 0o700 });
30
+ await this.recoverInterruptedValidationSymlink(runtime.authDir);
31
+ }
27
32
  const candidateNames = await this.collectCandidateNames();
28
33
  for (const name of candidateNames) {
29
34
  await this.reconcileCandidateAtStartup(name);
@@ -40,6 +45,7 @@ export class AuthCandidateMirror {
40
45
  await atomicCopy(path.join(this.canonicalDir, name), destination);
41
46
  }
42
47
  }
48
+ await this.recoverInterruptedValidationSymlink(runtime.authDir);
43
49
  if (defaultCandidate && !(await exists(path.join(runtime.authDir, 'auth.json')))) {
44
50
  await pointAuthSymlink(runtime.authDir, defaultCandidate);
45
51
  }
@@ -402,6 +408,54 @@ export class AuthCandidateMirror {
402
408
  const runtime = this.runtimes.find((entry) => entry.id === runtimeId);
403
409
  return runtime?.label ?? runtimeId;
404
410
  }
411
+ async recoverInterruptedValidationSymlink(authDir) {
412
+ const authPath = path.join(authDir, 'auth.json');
413
+ const oldTarget = await fs.readlink(authPath).catch(() => null);
414
+ if (!oldTarget)
415
+ return;
416
+ const oldTargetPath = path.resolve(authDir, oldTarget);
417
+ if (!path.basename(oldTargetPath).startsWith('.auth-sync-validate-'))
418
+ return;
419
+ const candidateName = await this.selectValidationRecoveryCandidate(authDir);
420
+ if (!candidateName) {
421
+ this.logger.warn('codex.auth_temp_symlink_recovery_failed', {
422
+ authDir,
423
+ oldTarget: oldTargetPath,
424
+ reason: 'no parseable auth candidate found',
425
+ });
426
+ return;
427
+ }
428
+ await pointAuthSymlink(authDir, candidateName);
429
+ await removeValidationTempFiles(authDir);
430
+ const newTarget = path.join(authDir, candidateName);
431
+ this.logger.warn('codex.auth_temp_symlink_recovered', {
432
+ authDir,
433
+ oldTarget: oldTargetPath,
434
+ newTarget,
435
+ });
436
+ }
437
+ async selectValidationRecoveryCandidate(authDir) {
438
+ const statusCandidate = this.lastStatus?.candidateName ?? null;
439
+ if (statusCandidate && isAuthCandidateName(statusCandidate)) {
440
+ const record = await readChatGptAuthRecord(path.join(authDir, statusCandidate));
441
+ if (record) {
442
+ return statusCandidate;
443
+ }
444
+ }
445
+ const candidates = [];
446
+ for (const name of await listAuthCandidateNames(authDir)) {
447
+ const candidatePath = path.join(authDir, name);
448
+ const [record, stat] = await Promise.all([
449
+ readChatGptAuthRecord(candidatePath),
450
+ fs.stat(candidatePath).catch(() => null),
451
+ ]);
452
+ if (record && stat?.isFile()) {
453
+ candidates.push({ name, mtimeMs: stat.mtimeMs });
454
+ }
455
+ }
456
+ candidates.sort((left, right) => right.mtimeMs - left.mtimeMs || left.name.localeCompare(right.name));
457
+ return candidates[0]?.name ?? null;
458
+ }
405
459
  }
406
460
  export function isAuthCandidateName(name) {
407
461
  return name !== 'auth.json'
@@ -461,6 +515,12 @@ async function pointAuthSymlink(dir, candidateName) {
461
515
  await fs.symlink(path.join(dir, candidateName), temporary);
462
516
  await fs.rename(temporary, path.join(dir, 'auth.json'));
463
517
  }
518
+ async function removeValidationTempFiles(dir) {
519
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
520
+ await Promise.all(entries
521
+ .filter((entry) => entry.isFile() && entry.name.startsWith('.auth-sync-validate-'))
522
+ .map((entry) => fs.rm(path.join(dir, entry.name), { force: true }).catch(() => undefined)));
523
+ }
464
524
  async function resolveFinalPath(sourcePath) {
465
525
  try {
466
526
  return await fs.realpath(sourcePath);
@@ -9,7 +9,9 @@ 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
- recoverAuthCandidate?: (runtimeId: string, candidateName: string) => Promise<boolean>;
12
+ recoverAuthCandidate?: (runtimeId: string, candidateName: string, options?: {
13
+ crossNode?: boolean;
14
+ }) => Promise<boolean>;
13
15
  acquireAuthRefreshLease?: (reason: string) => Promise<{
14
16
  ok: boolean;
15
17
  leaseId: string | null;
@@ -437,6 +437,14 @@ export class BridgeSessionCore {
437
437
  if (serviceStatus.authSync.lastError) {
438
438
  lines.push(t(locale, 'status_auth_sync_error', { value: serviceStatus.authSync.lastError }));
439
439
  }
440
+ if (serviceStatus.authSync.candidateFailures?.length) {
441
+ lines.push(t(locale, 'status_auth_sync_candidate_failures', {
442
+ value: serviceStatus.authSync.candidateFailures
443
+ .slice(0, 3)
444
+ .map((failure) => `${failure.candidateName}: ${failure.reason}`)
445
+ .join('; '),
446
+ }));
447
+ }
440
448
  }
441
449
  if (serviceStatus.lastUpdate) {
442
450
  lines.push(t(locale, 'status_last_update', {
@@ -3846,7 +3854,7 @@ export class BridgeSessionCore {
3846
3854
  await this.sendMessage(scopeId, t(locale, 'auth_reload_restarting'));
3847
3855
  const currentCandidate = (await this.listCodexAuthState()).candidates.find(candidate => candidate.isCurrent) ?? null;
3848
3856
  const recovered = currentCandidate
3849
- ? await this.recoverCodexAuthCandidate(currentCandidate.name)
3857
+ ? await this.recoverCodexAuthCandidate(currentCandidate.name, { crossNode: false })
3850
3858
  : false;
3851
3859
  this.pendingTurnErrors.clear();
3852
3860
  this.attachedThreads.clear();
@@ -4846,7 +4854,7 @@ export class BridgeSessionCore {
4846
4854
  };
4847
4855
  }
4848
4856
  async switchCodexAuthAndRestart(scopeId, locale, candidate, automatic, sendResult = true) {
4849
- const recovered = await this.recoverCodexAuthCandidate(candidate.name);
4857
+ const recovered = await this.recoverCodexAuthCandidate(candidate.name, { crossNode: automatic });
4850
4858
  const result = await switchCodexAuth(candidate.path, this.resolveAuthDir());
4851
4859
  this.authRotationFailedTargets.delete(candidate.path);
4852
4860
  this.pendingTurnErrors.clear();
@@ -4863,12 +4871,12 @@ export class BridgeSessionCore {
4863
4871
  lines.push(...await this.buildCodexUsageStatusLines(locale));
4864
4872
  await this.sendMessage(scopeId, lines.join('\n'));
4865
4873
  }
4866
- async recoverCodexAuthCandidate(candidateName) {
4874
+ async recoverCodexAuthCandidate(candidateName, options = { crossNode: true }) {
4867
4875
  if (!this.coordinator?.recoverAuthCandidate) {
4868
4876
  return false;
4869
4877
  }
4870
4878
  try {
4871
- return await this.coordinator.recoverAuthCandidate(this.authRuntimeId(), candidateName);
4879
+ return await this.coordinator.recoverAuthCandidate(this.authRuntimeId(), candidateName, options);
4872
4880
  }
4873
4881
  catch (error) {
4874
4882
  this.logger.warn('codex.auth_candidate_recovery_failed', {
@@ -8144,6 +8152,18 @@ function formatAuthSyncStatus(locale, status) {
8144
8152
  if (status.lastError) {
8145
8153
  lines.push(t(locale, 'auth_sync_status_error', { value: status.lastError }));
8146
8154
  }
8155
+ if (status.candidateFailures?.length) {
8156
+ lines.push(t(locale, 'auth_sync_status_candidate_failures'));
8157
+ for (const failure of status.candidateFailures.slice(0, 5)) {
8158
+ lines.push(t(locale, 'auth_sync_status_candidate_failure', {
8159
+ candidate: failure.candidateName,
8160
+ reason: failure.reason,
8161
+ source: failure.sourceLabel ?? failure.sourceNodeId ?? t(locale, 'unknown'),
8162
+ peer: failure.peer ?? t(locale, 'unknown'),
8163
+ time: failure.updatedAt,
8164
+ }));
8165
+ }
8166
+ }
8147
8167
  return lines.join('\n');
8148
8168
  }
8149
8169
  function normalizeHelpUsageKey(name) {
package/dist/i18n.d.ts CHANGED
@@ -127,6 +127,7 @@ declare const MESSAGES: {
127
127
  readonly status_auth_mirror_synced: "Last auth mirror: {candidate} from {source} at {time}";
128
128
  readonly status_auth_sync: "Cross-node auth sync: node {node}, contact {contact}, peers {peers}, pending imports {pending}";
129
129
  readonly status_auth_sync_error: "Cross-node auth sync error: {value}";
130
+ readonly status_auth_sync_candidate_failures: "Auth sync candidate failures: {value}";
130
131
  readonly status_last_update_none: "Last service update: none recorded";
131
132
  readonly status_last_update: "Last service update: {from} -> {to} at {time}";
132
133
  readonly status_last_codex_update: "Last Codex update: {value}";
@@ -218,6 +219,8 @@ declare const MESSAGES: {
218
219
  readonly auth_sync_status_pull: "Last pull: {value}";
219
220
  readonly auth_sync_status_lease: "Active refresh lease: {value}";
220
221
  readonly auth_sync_status_error: "Last error: {value}";
222
+ readonly auth_sync_status_candidate_failures: "Candidate failures:";
223
+ readonly auth_sync_status_candidate_failure: "- {candidate}: {reason} (source {source}, peer {peer}, at {time})";
221
224
  readonly button_login_device: "🔑 Login";
222
225
  readonly button_auth_reload: "🔄 Reload auth";
223
226
  readonly button_auth_refresh_all_confirm: "⚠️ Accept risk & refresh";
@@ -754,6 +757,7 @@ declare const MESSAGES: {
754
757
  readonly status_auth_mirror_synced: "最近 auth 镜像:{candidate} 由 {source} 于 {time} 同步";
755
758
  readonly status_auth_sync: "跨节点 auth 同步:节点 {node},联系人 {contact},peer {peers},待导入 {pending}";
756
759
  readonly status_auth_sync_error: "跨节点 auth 同步错误:{value}";
760
+ readonly status_auth_sync_candidate_failures: "auth 同步候选失败:{value}";
757
761
  readonly status_last_update_none: "最近服务升级:暂无记录";
758
762
  readonly status_last_update: "最近服务升级:{from} -> {to}({time})";
759
763
  readonly status_last_codex_update: "最近 Codex 升级:{value}";
@@ -845,6 +849,8 @@ declare const MESSAGES: {
845
849
  readonly auth_sync_status_pull: "最近拉取:{value}";
846
850
  readonly auth_sync_status_lease: "当前刷新锁:{value}";
847
851
  readonly auth_sync_status_error: "最近错误:{value}";
852
+ readonly auth_sync_status_candidate_failures: "候选失败:";
853
+ readonly auth_sync_status_candidate_failure: "- {candidate}:{reason}(来源 {source},peer {peer},时间 {time})";
848
854
  readonly button_login_device: "🔑 设备登录";
849
855
  readonly button_auth_reload: "🔄 重载 auth";
850
856
  readonly button_auth_refresh_all_confirm: "⚠️ 接受风险并刷新";
package/dist/i18n.js CHANGED
@@ -125,6 +125,7 @@ const MESSAGES = {
125
125
  status_auth_mirror_synced: 'Last auth mirror: {candidate} from {source} at {time}',
126
126
  status_auth_sync: 'Cross-node auth sync: node {node}, contact {contact}, peers {peers}, pending imports {pending}',
127
127
  status_auth_sync_error: 'Cross-node auth sync error: {value}',
128
+ status_auth_sync_candidate_failures: 'Auth sync candidate failures: {value}',
128
129
  status_last_update_none: 'Last service update: none recorded',
129
130
  status_last_update: 'Last service update: {from} -> {to} at {time}',
130
131
  status_last_codex_update: 'Last Codex update: {value}',
@@ -216,6 +217,8 @@ const MESSAGES = {
216
217
  auth_sync_status_pull: 'Last pull: {value}',
217
218
  auth_sync_status_lease: 'Active refresh lease: {value}',
218
219
  auth_sync_status_error: 'Last error: {value}',
220
+ auth_sync_status_candidate_failures: 'Candidate failures:',
221
+ auth_sync_status_candidate_failure: '- {candidate}: {reason} (source {source}, peer {peer}, at {time})',
219
222
  button_login_device: '🔑 Login',
220
223
  button_auth_reload: '🔄 Reload auth',
221
224
  button_auth_refresh_all_confirm: '⚠️ Accept risk & refresh',
@@ -752,6 +755,7 @@ const MESSAGES = {
752
755
  status_auth_mirror_synced: '最近 auth 镜像:{candidate} 由 {source} 于 {time} 同步',
753
756
  status_auth_sync: '跨节点 auth 同步:节点 {node},联系人 {contact},peer {peers},待导入 {pending}',
754
757
  status_auth_sync_error: '跨节点 auth 同步错误:{value}',
758
+ status_auth_sync_candidate_failures: 'auth 同步候选失败:{value}',
755
759
  status_last_update_none: '最近服务升级:暂无记录',
756
760
  status_last_update: '最近服务升级:{from} -> {to}({time})',
757
761
  status_last_codex_update: '最近 Codex 升级:{value}',
@@ -843,6 +847,8 @@ const MESSAGES = {
843
847
  auth_sync_status_pull: '最近拉取:{value}',
844
848
  auth_sync_status_lease: '当前刷新锁:{value}',
845
849
  auth_sync_status_error: '最近错误:{value}',
850
+ auth_sync_status_candidate_failures: '候选失败:',
851
+ auth_sync_status_candidate_failure: '- {candidate}:{reason}(来源 {source},peer {peer},时间 {time})',
846
852
  button_login_device: '🔑 设备登录',
847
853
  button_auth_reload: '🔄 重载 auth',
848
854
  button_auth_refresh_all_confirm: '⚠️ 接受风险并刷新',
package/dist/main.js CHANGED
@@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url';
9
9
  import { APP_HOME, DEFAULT_CODEX_TELEGRAM_HOME, DEFAULT_ENV_PATH, DEFAULT_LOG_PATH, DEFAULT_STATUS_PATH, getLoadedEnvPath, loadConfig, loadEnv, } from './config.js';
10
10
  import { acquireProcessLock, LockHeldError } from './lock.js';
11
11
  import { readRuntimeStatus, writeRuntimeStatus } from './runtime.js';
12
- import { refreshFoxclawExecStartDropIns, removeFoxclawExecStartDropIns } from './systemd.js';
12
+ import { buildFoxclawSystemdUnitText, refreshFoxclawExecStartDropIns, removeFoxclawExecStartDropIns } from './systemd.js';
13
13
  import { createSelfUpdateRuntime, inferPnpmHomeFromEntryPoint, performSelfUpdate, readSelfUpdateStatus, writeSelfUpdateStatus, } from './update.js';
14
14
  const rawCommand = process.argv[2];
15
15
  const command = rawCommand || 'serve';
@@ -288,10 +288,12 @@ async function runServeCli() {
288
288
  canSelfUpdate: () => authSyncLocalIdle()
289
289
  && (!authSync || authSync.isIdle()),
290
290
  authCandidateUpdated: (runtimeId, candidateName) => mirror.syncRuntimeCandidate(runtimeId, candidateName).then(() => undefined),
291
- recoverAuthCandidate: async (runtimeId, candidateName) => {
291
+ recoverAuthCandidate: async (runtimeId, candidateName, options = {}) => {
292
292
  const local = await mirror.recoverRuntimeCandidate(runtimeId, candidateName);
293
293
  if (local)
294
294
  return true;
295
+ if (options.crossNode === false)
296
+ return false;
295
297
  const current = await mirror.readRuntimeCandidate(runtimeId, candidateName)
296
298
  ?? await mirror.readNewestCandidate(candidateName);
297
299
  return await authSync?.requestRecovery(candidateName, {
@@ -442,10 +444,12 @@ async function runServeCli() {
442
444
  canSelfUpdate: () => singleAuthSyncLocalIdle()
443
445
  && (!singleAuthSync || singleAuthSync.isIdle()),
444
446
  authCandidateUpdated: (runtimeId, candidateName) => singleMirror?.syncRuntimeCandidate(runtimeId, candidateName).then(() => undefined) ?? Promise.resolve(),
445
- recoverAuthCandidate: async (runtimeId, candidateName) => {
447
+ recoverAuthCandidate: async (runtimeId, candidateName, options = {}) => {
446
448
  const local = await singleMirror?.recoverRuntimeCandidate(runtimeId, candidateName) ?? null;
447
449
  if (local)
448
450
  return true;
451
+ if (options.crossNode === false)
452
+ return false;
449
453
  const current = await singleMirror?.readRuntimeCandidate(runtimeId, candidateName)
450
454
  ?? await singleMirror?.readNewestCandidate(candidateName)
451
455
  ?? null;
@@ -612,13 +616,21 @@ function formatAuthSyncNotification(locale, event) {
612
616
  case 'remote_import_failed':
613
617
  return `${event.mode === 'pull' ? '跨节点拉取导入失败' : '跨节点 auth 导入失败'}:${event.candidateName}\n来源:${formatSource(event.sourceLabel, event.sourceNodeId)}\n原因:${event.reason}\n需要注意:如果其他候选也无法恢复,请人工介入重新登录或刷新这个 auth。`;
614
618
  case 'recovery_started':
615
- return `auth 恢复开始:${event.candidateName}\n处理:同节点没有可用较新副本,正在向跨节点 peer 查询:${peers}`;
619
+ return `auth 恢复开始:${event.candidateName}\nRequest:${event.requestId}\n处理:同节点没有可用较新副本,正在向跨节点 peer 查询:${peers},最长等待 ${event.timeoutMs}ms`;
616
620
  case 'recovery_peer_empty':
617
621
  return `auth 恢复收到 peer 回应但没有可用副本:${event.candidateName}\nPeer:${event.peer}\n原因:${event.reason}`;
618
622
  case 'recovery_peer_bundle_received':
619
623
  return `auth 恢复收到 peer 候选:${event.candidateName}\nPeer:${event.peer},来源节点:${event.sourceNodeId}\n处理:正在验证 usage 后导入。`;
620
624
  case 'recovery_failed':
621
- return `auth 恢复已穷尽:${event.candidateName}\n已查询 peer:${peers}\n原因:${event.reason}\n请人工介入:使用 /auth add <name> 或设备登录重新生成可用 auth。`;
625
+ return [
626
+ `auth 恢复已穷尽:${event.candidateName}`,
627
+ event.requestId ? `Request:${event.requestId}` : null,
628
+ `已查询 peer:${peers}`,
629
+ event.waitMs !== undefined ? `等待:${event.waitMs}ms` : null,
630
+ formatPeerReachability(event.peerReachability, 'zh'),
631
+ `原因:${event.reason}`,
632
+ '请人工介入:使用 /auth add <name> 或设备登录重新生成可用 auth。',
633
+ ].filter(Boolean).join('\n');
622
634
  case 'pull_request_received':
623
635
  return `收到 peer 的 auth 查询:${event.candidateName}\nPeer:${event.peer},请求节点:${event.requesterNodeId}`;
624
636
  case 'pull_response_sent':
@@ -653,13 +665,21 @@ function formatAuthSyncNotification(locale, event) {
653
665
  case 'remote_import_failed':
654
666
  return `${event.mode === 'pull' ? 'Cross-node pull import failed' : 'Cross-node auth import failed'}: ${event.candidateName}\nSource: ${formatSource(event.sourceLabel, event.sourceNodeId)}\nReason: ${event.reason}\nAttention: if no other candidate can recover this account, run device login or refresh this auth manually.`;
655
667
  case 'recovery_started':
656
- return `Auth recovery started: ${event.candidateName}\nAction: no newer same-node copy was available; querying cross-node peers: ${peers}`;
668
+ return `Auth recovery started: ${event.candidateName}\nRequest: ${event.requestId}\nAction: no newer same-node copy was available; querying cross-node peers: ${peers}; timeout ${event.timeoutMs}ms`;
657
669
  case 'recovery_peer_empty':
658
670
  return `Auth recovery peer replied without usable auth: ${event.candidateName}\nPeer: ${event.peer}\nReason: ${event.reason}`;
659
671
  case 'recovery_peer_bundle_received':
660
672
  return `Auth recovery peer returned a candidate: ${event.candidateName}\nPeer: ${event.peer}, source node: ${event.sourceNodeId}\nAction: validating usage before import.`;
661
673
  case 'recovery_failed':
662
- return `Auth recovery exhausted: ${event.candidateName}\nPeers checked: ${peers}\nReason: ${event.reason}\nManual action: use /auth add <name> or device login to create a usable auth.`;
674
+ return [
675
+ `Auth recovery exhausted: ${event.candidateName}`,
676
+ event.requestId ? `Request: ${event.requestId}` : null,
677
+ `Peers checked: ${peers}`,
678
+ event.waitMs !== undefined ? `Wait: ${event.waitMs}ms` : null,
679
+ formatPeerReachability(event.peerReachability, 'en'),
680
+ `Reason: ${event.reason}`,
681
+ 'Manual action: use /auth add <name> or device login to create a usable auth.',
682
+ ].filter(Boolean).join('\n');
663
683
  case 'pull_request_received':
664
684
  return `Received peer auth recovery request: ${event.candidateName}\nPeer: ${event.peer}, requester node: ${event.requesterNodeId}`;
665
685
  case 'pull_response_sent':
@@ -671,6 +691,17 @@ function formatAuthSyncNotification(locale, event) {
671
691
  function formatPeerList(peers, locale) {
672
692
  return peers.length > 0 ? peers.join(', ') : (locale === 'zh' ? '无' : 'none');
673
693
  }
694
+ function formatPeerReachability(reachability, locale) {
695
+ const reachable = (reachability ?? []).filter((entry) => entry.reachableDuringRequest);
696
+ if (reachable.length === 0)
697
+ return null;
698
+ const details = reachable
699
+ .map((entry) => `${entry.peer}${entry.lastReceivedAt ? ` @ ${entry.lastReceivedAt}` : ''}`)
700
+ .join(', ');
701
+ return locale === 'zh'
702
+ ? `注意:这些 peer 在本次等待期间有其他 auth sync 消息,说明 peer 可达但这个请求超时:${details}`
703
+ : `Note: these peers sent other auth sync messages during this wait, so the peer was reachable but this request timed out: ${details}`;
704
+ }
674
705
  function formatSource(sourceLabel, sourceNodeId) {
675
706
  return sourceLabel === sourceNodeId ? sourceNodeId : `${sourceLabel} / ${sourceNodeId}`;
676
707
  }
@@ -1168,32 +1199,15 @@ function installSystemd() {
1168
1199
  fs.mkdirSync(configDir, { recursive: true });
1169
1200
  fs.mkdirSync(path.join(APP_HOME, 'logs'), { recursive: true });
1170
1201
  const escapedEntryPoint = systemdEscape(entryPoint);
1171
- fs.writeFileSync(unitPath, `[Unit]
1172
- Description=FoxClaw local Codex execution bridge
1173
- Documentation=https://github.com/foxden-app/foxclaw
1174
- After=network-online.target
1175
- Wants=network-online.target
1176
- StartLimitIntervalSec=300
1177
- StartLimitBurst=5
1178
-
1179
- [Service]
1180
- Type=simple
1181
- WorkingDirectory=${systemdEscape(configDir)}
1182
- EnvironmentFile=-${systemdEscape(envPath)}
1183
- Environment=HOME=${systemdEscape(process.env.HOME || '')}
1184
- Environment=USER=${systemdEscape(process.env.USER || '')}
1185
- Environment=LOGNAME=${systemdEscape(process.env.LOGNAME || process.env.USER || '')}
1186
- Environment=PATH=${systemdEscape(pathValue)}
1187
- Environment=FOXCLAW_ENV=${systemdEscape(envPath)}
1188
- ExecStart=${execStart}
1189
- Restart=always
1190
- RestartSec=10
1191
- TimeoutStopSec=45
1192
- KillMode=process
1193
-
1194
- [Install]
1195
- WantedBy=default.target
1196
- `);
1202
+ fs.writeFileSync(unitPath, buildFoxclawSystemdUnitText({
1203
+ workingDirectory: systemdEscape(configDir),
1204
+ envPath: systemdEscape(envPath),
1205
+ home: systemdEscape(process.env.HOME || ''),
1206
+ user: systemdEscape(process.env.USER || ''),
1207
+ logname: systemdEscape(process.env.LOGNAME || process.env.USER || ''),
1208
+ pathValue: systemdEscape(pathValue),
1209
+ execStart,
1210
+ }));
1197
1211
  if (proxychainsConf) {
1198
1212
  console.log(`[OK] systemd proxychains enabled: ${proxychainsConf}`);
1199
1213
  }
package/dist/systemd.d.ts CHANGED
@@ -2,6 +2,16 @@ export interface SystemdDropInUpdate {
2
2
  path: string;
3
3
  replacements: number;
4
4
  }
5
+ export interface FoxclawSystemdUnitTextOptions {
6
+ workingDirectory: string;
7
+ envPath: string;
8
+ home: string;
9
+ user: string;
10
+ logname: string;
11
+ pathValue: string;
12
+ execStart: string;
13
+ }
14
+ export declare function buildFoxclawSystemdUnitText(options: FoxclawSystemdUnitTextOptions): string;
5
15
  export declare function refreshFoxclawExecStartDropIns(userSystemdDir: string, unitName: string, escapedEntryPoint: string): SystemdDropInUpdate[];
6
16
  export declare function removeFoxclawExecStartDropIns(userSystemdDir: string, unitName: string): SystemdDropInUpdate[];
7
17
  export declare function refreshFoxclawExecStartText(text: string, escapedEntryPoint: string): {
package/dist/systemd.js CHANGED
@@ -1,6 +1,34 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  const FOXCLAW_MAIN_PATH_RE = /\S*(?:\.pnpm\/@foxden-app\+foxclaw@[^/\s]+\/node_modules\/@foxden-app\/foxclaw|node_modules\/@foxden-app\/foxclaw)\/dist\/main\.js/g;
4
+ export function buildFoxclawSystemdUnitText(options) {
5
+ return `[Unit]
6
+ Description=FoxClaw local Codex execution bridge
7
+ Documentation=https://github.com/foxden-app/foxclaw
8
+ After=network-online.target
9
+ Wants=network-online.target
10
+ StartLimitIntervalSec=300
11
+ StartLimitBurst=5
12
+
13
+ [Service]
14
+ Type=simple
15
+ WorkingDirectory=${options.workingDirectory}
16
+ EnvironmentFile=-${options.envPath}
17
+ Environment=HOME=${options.home}
18
+ Environment=USER=${options.user}
19
+ Environment=LOGNAME=${options.logname}
20
+ Environment=PATH=${options.pathValue}
21
+ Environment=FOXCLAW_ENV=${options.envPath}
22
+ ExecStart=${options.execStart}
23
+ Restart=always
24
+ RestartSec=10
25
+ TimeoutStopSec=45
26
+ KillMode=control-group
27
+
28
+ [Install]
29
+ WantedBy=default.target
30
+ `;
31
+ }
4
32
  export function refreshFoxclawExecStartDropIns(userSystemdDir, unitName, escapedEntryPoint) {
5
33
  const dropInDir = path.join(userSystemdDir, `${unitName}.d`);
6
34
  let names;
package/dist/types.d.ts CHANGED
@@ -373,6 +373,15 @@ export interface RuntimeStatus {
373
373
  lastPullAt: string | null;
374
374
  lastPullCandidate: string | null;
375
375
  lastError: string | null;
376
+ candidateFailures?: Array<{
377
+ candidateName: string;
378
+ reason: string;
379
+ sourceNodeId: string | null;
380
+ sourceLabel: string | null;
381
+ peer: string | null;
382
+ mode: 'push' | 'pull';
383
+ updatedAt: string;
384
+ }>;
376
385
  activeLeaseId: string | null;
377
386
  } | null;
378
387
  lastUpdate?: {
package/dist/update.d.ts CHANGED
@@ -40,6 +40,12 @@ interface PerformSelfUpdateOptions {
40
40
  codexCliBin?: string;
41
41
  env?: NodeJS.ProcessEnv;
42
42
  }
43
+ export interface SelfUpdateLaunchCommand {
44
+ command: string;
45
+ args: string[];
46
+ env: NodeJS.ProcessEnv;
47
+ viaSystemdRun: boolean;
48
+ }
43
49
  export interface SelfUpdateOutcome {
44
50
  ok: boolean;
45
51
  fromVersion: string;
@@ -53,5 +59,16 @@ export declare function resolveCodexUpdateInstaller(codexCliBin: string, nodePat
53
59
  export declare function readSelfUpdateStatus(statusFile: string): SelfUpdateStatus | null;
54
60
  export declare function writeSelfUpdateStatus(statusFile: string, status: SelfUpdateStatus): void;
55
61
  export declare function createSelfUpdateRuntime(options: CreateSelfUpdateRuntimeOptions): SelfUpdateRuntime;
62
+ export declare function buildSelfUpdateLaunchCommand(options: {
63
+ entryPoint: string;
64
+ nodePath: string;
65
+ statusFile: string;
66
+ logPath: string;
67
+ codexCliBin?: string;
68
+ env?: NodeJS.ProcessEnv;
69
+ platform?: NodeJS.Platform;
70
+ systemdRunPath?: string | null;
71
+ unitName?: string;
72
+ }): SelfUpdateLaunchCommand;
56
73
  export declare function performSelfUpdate(options: PerformSelfUpdateOptions): SelfUpdateOutcome;
57
74
  export {};
package/dist/update.js CHANGED
@@ -171,12 +171,33 @@ export function createSelfUpdateRuntime(options) {
171
171
  fs.mkdirSync(path.dirname(options.logPath), { recursive: true });
172
172
  const logFd = fs.openSync(options.logPath, 'a', 0o600);
173
173
  try {
174
- const child = spawn(options.nodePath, [options.entryPoint, 'update', '--notification-file', statusFile], {
175
- detached: true,
176
- stdio: ['ignore', logFd, logFd],
177
- env: options.codexCliBin ? { ...process.env, CODEX_CLI_BIN: options.codexCliBin } : process.env,
174
+ const launch = buildSelfUpdateLaunchCommand({
175
+ entryPoint: options.entryPoint,
176
+ nodePath: options.nodePath,
177
+ statusFile,
178
+ logPath: options.logPath,
179
+ ...(options.codexCliBin ? { codexCliBin: options.codexCliBin } : {}),
178
180
  });
179
- child.unref();
181
+ if (launch.viaSystemdRun) {
182
+ const result = spawnSync(launch.command, launch.args, {
183
+ stdio: ['ignore', logFd, logFd],
184
+ env: launch.env,
185
+ });
186
+ if (result.error) {
187
+ throw result.error;
188
+ }
189
+ if (result.status !== 0) {
190
+ throw new Error(`${launch.command} ${launch.args.join(' ')} exited with status ${result.status ?? 'unknown'}.`);
191
+ }
192
+ }
193
+ else {
194
+ const child = spawn(launch.command, launch.args, {
195
+ detached: true,
196
+ stdio: ['ignore', logFd, logFd],
197
+ env: launch.env,
198
+ });
199
+ child.unref();
200
+ }
180
201
  }
181
202
  catch (error) {
182
203
  writeSelfUpdateStatus(statusFile, {
@@ -205,6 +226,40 @@ export function createSelfUpdateRuntime(options) {
205
226
  },
206
227
  };
207
228
  }
229
+ export function buildSelfUpdateLaunchCommand(options) {
230
+ const env = options.codexCliBin
231
+ ? { ...(options.env ?? process.env), CODEX_CLI_BIN: options.codexCliBin }
232
+ : { ...(options.env ?? process.env) };
233
+ const updateArgs = [options.entryPoint, 'update', '--notification-file', options.statusFile];
234
+ const platform = options.platform ?? process.platform;
235
+ const systemdRunPath = options.systemdRunPath === undefined
236
+ ? resolveCommand('systemd-run', env)
237
+ : options.systemdRunPath;
238
+ if (platform === 'linux' && systemdRunPath) {
239
+ const unitName = options.unitName ?? `foxclaw-update-${process.pid}-${Date.now()}`;
240
+ return {
241
+ command: systemdRunPath,
242
+ args: [
243
+ '--user',
244
+ '--collect',
245
+ `--unit=${unitName}`,
246
+ `--property=StandardOutput=append:${options.logPath}`,
247
+ `--property=StandardError=append:${options.logPath}`,
248
+ ...systemdSetEnvArgs(env),
249
+ options.nodePath,
250
+ ...updateArgs,
251
+ ],
252
+ env,
253
+ viaSystemdRun: true,
254
+ };
255
+ }
256
+ return {
257
+ command: options.nodePath,
258
+ args: updateArgs,
259
+ env,
260
+ viaSystemdRun: false,
261
+ };
262
+ }
208
263
  export function performSelfUpdate(options) {
209
264
  const env = options.env ?? process.env;
210
265
  let toVersion = null;
@@ -295,6 +350,31 @@ function executableCandidates(commandName, nodePath, env, preferred = []) {
295
350
  ...(env.PATH || '').split(path.delimiter).filter(Boolean).map((dir) => path.join(dir, commandName)),
296
351
  ].filter((candidate, index, all) => candidate && all.indexOf(candidate) === index);
297
352
  }
353
+ function resolveCommand(commandName, env) {
354
+ for (const directory of (env.PATH || '').split(path.delimiter).filter(Boolean)) {
355
+ const candidate = path.join(directory, commandName);
356
+ if (fs.existsSync(candidate)) {
357
+ return candidate;
358
+ }
359
+ }
360
+ for (const fallback of ['/usr/bin', '/bin', '/usr/local/bin']) {
361
+ const candidate = path.join(fallback, commandName);
362
+ if (fs.existsSync(candidate)) {
363
+ return candidate;
364
+ }
365
+ }
366
+ return null;
367
+ }
368
+ function systemdSetEnvArgs(env) {
369
+ return Object.entries(env)
370
+ .filter((entry) => {
371
+ const [key, value] = entry;
372
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key)
373
+ && typeof value === 'string'
374
+ && !value.includes('\0');
375
+ })
376
+ .map(([key, value]) => `--setenv=${key}=${value}`);
377
+ }
298
378
  function buildInstallerEnv(entryPoint, installer, env) {
299
379
  const pnpmHome = installer.manager === 'pnpm'
300
380
  ? installer.pnpmHome ?? inferPnpmHomeFromEntryPoint(entryPoint)
@@ -168,6 +168,12 @@ Note: `/auth sync push all` saying “sent” only means this node successfully
168
168
 
169
169
  When cross-node sync is enabled, the contact bot private chat receives node-level notifications: local auth updates and the peers being contacted, received remote bundles and whether they were queued or immediately validated, import success/skip/failure reasons, recovery peer queries and peer replies, and a manual-intervention notice when every peer lacks an importable copy. Notifications never include auth contents, tokens, or encrypted bundle payloads.
170
170
 
171
+ Starting in 0.5.2, `/auth sync status` separates sync-system `Last error` from per-auth `Candidate failures`. For example, a remote candidate that returns `token_invalidated` or has an expired access token is recorded under that candidate name only; current `auth.json` health is still determined by validating the current auth usage. `local candidate is already newer or equal` is a normal skip, not an error.
172
+
173
+ Manual `/auth` switches and `/auth reload` recover from same-node local mirrors only and do not send cross-node pull requests. FoxClaw queries peers only during automatic recovery after it detects a real auth problem. Recovery timeout notifications include the request id, candidate name, peer list, and wait duration; if another auth sync message arrived from the same peer during that wait, the notification says the peer was reachable but this request timed out.
174
+
175
+ If an upgrade or restart interrupts remote candidate usage validation, older versions could leave `auth.json -> .auth-sync-validate-*` behind. Starting in 0.5.2, FoxClaw checks for this at startup, restores `auth.json` to the mirror-status candidate or the newest parseable real `auth.json_*` candidate in the same directory, and removes stale validation temp files.
176
+
171
177
  5. Only test refresh-token rotation after you understand the risk:
172
178
 
173
179
  ```text
@@ -419,7 +419,7 @@ OpenAI does not publish a fixed ChatGPT refresh-token lifetime or an old-token r
419
419
 
420
420
  ### 6.4 Cross-Node Auth Sync
421
421
 
422
- Cross-node auth sync is disabled by default. It is for multiple machines you control that share the same legally owned ChatGPT auth candidate pool, so a token refreshed by Codex on one node can be copied to the others. v1 uses Telegram Bot-to-Bot private messages to carry encrypted files, so it does not require public IPs or FRP. The recommended default is one contact bot per node; other bots on the same node keep using local auth mirroring. In multi-bot mode, the default contact is the first token in `TG_BOT_TOKENS`. The contact bot private chat reports send, receive, queue, import, failure, recovery-query, and manual-intervention states.
422
+ Cross-node auth sync is disabled by default. It is for multiple machines you control that share the same legally owned ChatGPT auth candidate pool, so a token refreshed by Codex on one node can be copied to the others. v1 uses Telegram Bot-to-Bot private messages to carry encrypted files, so it does not require public IPs or FRP. The recommended default is one contact bot per node; other bots on the same node keep using local auth mirroring. In multi-bot mode, the default contact is the first token in `TG_BOT_TOKENS`. The contact bot private chat reports send, receive, queue, import, failure, recovery-query, and manual-intervention states; per-candidate validation failures are shown as candidate failures instead of overwriting the sync-system last error.
423
423
 
424
424
  For the full design, safety boundaries, `.env` examples, and troubleshooting, read the [Cross-Node Auth Sync Setup Guide](./cross-node-auth-sync.md).
425
425
 
@@ -460,7 +460,7 @@ Dual-active behavior:
460
460
 
461
461
  Commands:
462
462
 
463
- - `/auth sync status`: show node id, peers, recent sends/receives/imports, pending imports, and the latest error.
463
+ - `/auth sync status`: show node id, peers, recent sends/receives/imports, pending imports, the sync-system latest error, and per-candidate failures.
464
464
  - `/auth sync test`: send an encrypted ping and wait for peer pong replies to verify peer config, shared key, and Bot-to-Bot private messages.
465
465
  - `/auth sync push all`: manually broadcast all locally verified candidates without refreshing tokens. “Sent” does not mean the peer imported files; check `/auth sync status` and `/auth` on the peer.
466
466
 
@@ -168,6 +168,12 @@ auth sync 测试完成:已发送 1,收到回应 1。
168
168
 
169
169
  启用跨节点同步后,联系人 bot 的私聊会收到节点级通知:本机 auth 更新并开始发往哪些 peer、收到远端包后是排队还是立即验证、导入成功/跳过/失败原因、auth 恢复时正在查询哪些 peer、peer 回应了什么,以及所有 peer 都无法提供可用副本时的人工介入提示。通知不会包含 auth 内容、token 或同步密文。
170
170
 
171
+ 从 0.5.2 起,`/auth sync status` 会把同步系统级 `最近错误` 和单个 auth 的 `候选失败` 分开显示。比如某个远端候选返回 `token_invalidated` 或 access token 过期时,只会记录到该候选名下面;当前 `auth.json` 是否健康仍以当前 auth 的 usage 验证为准。`local candidate is already newer or equal` 属于正常跳过,不会记为错误。
172
+
173
+ 手动 `/auth` 切换和 `/auth reload` 只会尝试同节点本地 mirror 恢复,不会主动向跨节点 peer 发起 pull。只有 FoxClaw 检测到当前 auth 真的出现认证问题并进入自动恢复时,才会向 peer 查询可用副本。恢复超时通知会包含 request id、候选名、peer 列表和等待时长;如果等待期间收到过同 peer 的其他 auth sync 消息,通知会标明 peer 可达但该请求超时。
174
+
175
+ 如果升级或重启正好打断远端候选 usage 验证,旧版本可能留下 `auth.json -> .auth-sync-validate-*` 临时 symlink。0.5.2 起 FoxClaw 启动时会自动检测并恢复到 mirror 状态记录的候选,或同目录最近修改且可解析的真实 `auth.json_*` 候选,然后清理临时文件。
176
+
171
177
  5. 只有在完全理解 refresh token 轮换风险时,才测试:
172
178
 
173
179
  ```text
@@ -419,7 +419,7 @@ OpenAI 没有公开 ChatGPT refresh token 的固定有效期或旧 token 重放
419
419
 
420
420
  ### 6.4 跨节点 auth 同步
421
421
 
422
- 跨节点 auth 同步默认关闭。它适合你在多台自己控制的机器上使用同一组合法 ChatGPT 账号候选,并希望某台机器上 Codex 自动刷新出的新 token 能同步到其他机器。v1 使用 Telegram Bot-to-Bot 私聊传输加密文件,不需要公网 IP 或 FRP。推荐每台机器选择一个联系人 bot;同一节点内其他 bot 继续使用本机 auth 镜像。多 bot 模式下,默认联系人是 `TG_BOT_TOKENS` 的第一个 token。联系人 bot 的私聊会报告发送、接收、排队、导入、失败、恢复查询和人工介入提示。
422
+ 跨节点 auth 同步默认关闭。它适合你在多台自己控制的机器上使用同一组合法 ChatGPT 账号候选,并希望某台机器上 Codex 自动刷新出的新 token 能同步到其他机器。v1 使用 Telegram Bot-to-Bot 私聊传输加密文件,不需要公网 IP 或 FRP。推荐每台机器选择一个联系人 bot;同一节点内其他 bot 继续使用本机 auth 镜像。多 bot 模式下,默认联系人是 `TG_BOT_TOKENS` 的第一个 token。联系人 bot 的私聊会报告发送、接收、排队、导入、失败、恢复查询和人工介入提示;单个候选验证失败会作为“候选失败”显示,不会覆盖同步系统级最近错误。
423
423
 
424
424
  完整设计、安全边界、`.env` 示例和排查步骤见 [跨节点 auth 同步配置指南](./cross-node-auth-sync.md)。
425
425
 
@@ -460,7 +460,7 @@ AUTH_SYNC_NODE_ID=workstation-a
460
460
 
461
461
  命令:
462
462
 
463
- - `/auth sync status`:查看 node id、peer、最近收发、最近导入、待导入和最近错误。
463
+ - `/auth sync status`:查看 node id、peer、最近收发、最近导入、待导入、系统级最近错误和单候选失败。
464
464
  - `/auth sync test`:发送加密 ping 并等待 peer 返回 pong,确认 peer、共享密钥和 Bot-to-Bot 私聊可用。
465
465
  - `/auth sync push all`:手动广播当前节点已验证的全部候选,不刷新 token;“已发送”不等于对端已经导入,需要在 peer 上看 `/auth sync status` 和 `/auth`。
466
466
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",