@foxden-app/foxclaw 0.4.13 → 0.4.14

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.
@@ -12,6 +12,9 @@ export interface AuthMirrorStatus {
12
12
  sourceLabel: string;
13
13
  syncedAt: string;
14
14
  }
15
+ export interface AuthMirrorExternalStatus extends AuthMirrorStatus {
16
+ remoteNodeId?: string | null;
17
+ }
15
18
  export interface AuthMirrorRecovery {
16
19
  candidateName: string;
17
20
  sourceRuntimeId: string;
@@ -31,26 +34,55 @@ export interface ChatGptAuthMetadata {
31
34
  accountId: string;
32
35
  lastRefreshMs: number;
33
36
  }
37
+ export interface ChatGptAuthRecord extends ChatGptAuthMetadata {
38
+ raw: string;
39
+ }
40
+ export interface AuthMirrorCandidateRecord extends ChatGptAuthRecord {
41
+ candidateName: string;
42
+ sourceRuntimeId: string;
43
+ sourceLabel: string;
44
+ }
45
+ export interface AuthMirrorImportResult {
46
+ ok: boolean;
47
+ imported: boolean;
48
+ reason?: string | null;
49
+ record?: AuthMirrorCandidateRecord;
50
+ }
51
+ export interface AuthMirrorSyncedEvent {
52
+ status: AuthMirrorStatus;
53
+ record: AuthMirrorCandidateRecord;
54
+ }
55
+ export interface AuthMirrorHooks {
56
+ onSynced?: (event: AuthMirrorSyncedEvent) => Promise<void> | void;
57
+ }
34
58
  export declare class AuthCandidateMirror {
35
59
  private readonly canonicalDir;
36
60
  private readonly runtimes;
37
61
  private readonly logger;
38
62
  private readonly statusPath;
63
+ private readonly hooks;
39
64
  private timer;
40
65
  private readonly lastSyncedRefresh;
41
66
  private readonly lastValidationFailures;
42
67
  private readonly activeCandidateSyncs;
43
68
  private activeOperations;
44
69
  private lastStatus;
45
- constructor(canonicalDir: string, runtimes: AuthMirrorRuntime[], logger: Logger, statusPath?: string | null);
70
+ constructor(canonicalDir: string, runtimes: AuthMirrorRuntime[], logger: Logger, statusPath?: string | null, hooks?: AuthMirrorHooks);
46
71
  initialize(): Promise<void>;
47
72
  start(): void;
48
73
  stop(): void;
49
74
  isIdle(): boolean;
50
75
  getStatus(): AuthMirrorStatus | null;
76
+ readNewestCandidate(candidateName: string): Promise<AuthMirrorCandidateRecord | null>;
77
+ readRuntimeCandidate(runtimeId: string, candidateName: string): Promise<AuthMirrorCandidateRecord | null>;
78
+ listNewestCandidates(): Promise<AuthMirrorCandidateRecord[]>;
51
79
  syncRuntimeCandidate(runtimeId: string, candidateName: string): Promise<boolean>;
52
80
  recoverRuntimeCandidate(runtimeId: string, candidateName: string): Promise<AuthMirrorRecovery | null>;
53
81
  private scan;
82
+ importExternalCandidate(candidateName: string, raw: string, source: {
83
+ nodeId: string;
84
+ label?: string | null;
85
+ }): Promise<AuthMirrorImportResult>;
54
86
  private propagateValidatedCandidate;
55
87
  private validateRuntimeCandidate;
56
88
  private withActivity;
@@ -59,6 +91,10 @@ export declare class AuthCandidateMirror {
59
91
  private collectAuthRecords;
60
92
  private reconcileCandidateAtStartup;
61
93
  private resolveCanonicalCurrentCandidate;
94
+ private findNewestRecord;
95
+ private runtimeLabel;
62
96
  }
63
97
  export declare function isAuthCandidateName(name: string): boolean;
98
+ export declare function readChatGptAuthRecord(filePath: string): Promise<ChatGptAuthRecord | null>;
64
99
  export declare function readChatGptAuthMetadata(filePath: string): Promise<ChatGptAuthMetadata | null>;
100
+ export declare function parseChatGptAuthMetadata(raw: string): ChatGptAuthMetadata | null;
@@ -6,17 +6,19 @@ export class AuthCandidateMirror {
6
6
  runtimes;
7
7
  logger;
8
8
  statusPath;
9
+ hooks;
9
10
  timer = null;
10
11
  lastSyncedRefresh = new Map();
11
12
  lastValidationFailures = new Map();
12
13
  activeCandidateSyncs = new Set();
13
14
  activeOperations = 0;
14
15
  lastStatus = null;
15
- constructor(canonicalDir, runtimes, logger, statusPath = null) {
16
+ constructor(canonicalDir, runtimes, logger, statusPath = null, hooks = {}) {
16
17
  this.canonicalDir = canonicalDir;
17
18
  this.runtimes = runtimes;
18
19
  this.logger = logger;
19
20
  this.statusPath = statusPath;
21
+ this.hooks = hooks;
20
22
  }
21
23
  async initialize() {
22
24
  this.lastStatus = await readMirrorStatus(this.statusPath);
@@ -65,6 +67,51 @@ export class AuthCandidateMirror {
65
67
  getStatus() {
66
68
  return this.lastStatus;
67
69
  }
70
+ async readNewestCandidate(candidateName) {
71
+ if (!isAuthCandidateName(candidateName))
72
+ return null;
73
+ return this.withActivity(async () => this.findNewestRecord((entry) => entry.candidateName === candidateName));
74
+ }
75
+ async readRuntimeCandidate(runtimeId, candidateName) {
76
+ if (!isAuthCandidateName(candidateName))
77
+ return null;
78
+ return this.withActivity(async () => {
79
+ const runtime = this.runtimes.find((entry) => entry.id === runtimeId);
80
+ const authDir = runtimeId === 'canonical' ? this.canonicalDir : runtime?.authDir;
81
+ if (!authDir)
82
+ return null;
83
+ const record = await readChatGptAuthRecord(path.join(authDir, candidateName));
84
+ if (!record)
85
+ return null;
86
+ return {
87
+ ...record,
88
+ candidateName,
89
+ sourceRuntimeId: runtimeId,
90
+ sourceLabel: runtime?.label ?? runtimeId,
91
+ };
92
+ });
93
+ }
94
+ async listNewestCandidates() {
95
+ return this.withActivity(async () => {
96
+ const byName = new Map();
97
+ for (const entry of await this.collectAuthRecords()) {
98
+ if (!isAuthCandidateName(entry.candidateName))
99
+ continue;
100
+ const sourceLabel = this.runtimeLabel(entry.runtimeId);
101
+ const candidate = {
102
+ ...entry.record,
103
+ candidateName: entry.candidateName,
104
+ sourceRuntimeId: entry.runtimeId,
105
+ sourceLabel,
106
+ };
107
+ const current = byName.get(entry.candidateName);
108
+ if (!current || candidate.lastRefreshMs > current.lastRefreshMs) {
109
+ byName.set(entry.candidateName, candidate);
110
+ }
111
+ }
112
+ return [...byName.values()].sort((a, b) => a.candidateName.localeCompare(b.candidateName));
113
+ });
114
+ }
68
115
  async syncRuntimeCandidate(runtimeId, candidateName) {
69
116
  if (!isAuthCandidateName(candidateName))
70
117
  return false;
@@ -116,6 +163,64 @@ export class AuthCandidateMirror {
116
163
  }
117
164
  });
118
165
  }
166
+ async importExternalCandidate(candidateName, raw, source) {
167
+ if (!isAuthCandidateName(candidateName)) {
168
+ return { ok: false, imported: false, reason: 'invalid candidate name' };
169
+ }
170
+ return this.withActivity(async () => {
171
+ if (this.activeCandidateSyncs.has(candidateName)) {
172
+ return { ok: false, imported: false, reason: 'candidate sync already active' };
173
+ }
174
+ this.activeCandidateSyncs.add(candidateName);
175
+ try {
176
+ const metadata = parseChatGptAuthMetadata(raw);
177
+ if (!metadata) {
178
+ return { ok: false, imported: false, reason: 'invalid ChatGPT auth payload' };
179
+ }
180
+ const existing = (await this.collectAuthRecords())
181
+ .filter(entry => entry.candidateName === candidateName);
182
+ const conflicting = existing.find(entry => entry.record.accountId !== metadata.accountId);
183
+ if (conflicting) {
184
+ return { ok: false, imported: false, reason: 'same candidate belongs to a different account' };
185
+ }
186
+ const newest = existing.reduce((current, entry) => (!current || entry.record.lastRefreshMs > current.record.lastRefreshMs ? entry : current), null);
187
+ const previousRefresh = Math.max(newest?.record.lastRefreshMs ?? 0, this.lastSyncedRefresh.get(candidateName) ?? 0);
188
+ if (metadata.lastRefreshMs <= previousRefresh) {
189
+ return { ok: true, imported: false, reason: 'local candidate is already newer or equal' };
190
+ }
191
+ await atomicWrite(path.join(this.canonicalDir, candidateName), raw);
192
+ for (const target of this.runtimes) {
193
+ await atomicWrite(path.join(target.authDir, candidateName), raw);
194
+ }
195
+ this.lastSyncedRefresh.set(candidateName, metadata.lastRefreshMs);
196
+ const sourceLabel = source.label?.trim() || source.nodeId;
197
+ this.lastStatus = {
198
+ candidateName,
199
+ sourceRuntimeId: `remote:${source.nodeId}`,
200
+ sourceLabel,
201
+ syncedAt: new Date().toISOString(),
202
+ };
203
+ await writeMirrorStatus(this.statusPath, this.lastStatus);
204
+ this.logger.info('auth.mirror.remote_imported', { candidateName, sourceNodeId: source.nodeId });
205
+ const message = `${candidateName} has been synchronized from remote node ${sourceLabel}.`;
206
+ await Promise.allSettled(this.runtimes.map((target) => target.notify?.(message)));
207
+ return {
208
+ ok: true,
209
+ imported: true,
210
+ record: {
211
+ raw,
212
+ ...metadata,
213
+ candidateName,
214
+ sourceRuntimeId: `remote:${source.nodeId}`,
215
+ sourceLabel,
216
+ },
217
+ };
218
+ }
219
+ finally {
220
+ this.activeCandidateSyncs.delete(candidateName);
221
+ }
222
+ });
223
+ }
119
224
  async propagateValidatedCandidate(runtime, name) {
120
225
  if (this.activeCandidateSyncs.has(name)) {
121
226
  return false;
@@ -170,6 +275,15 @@ export class AuthCandidateMirror {
170
275
  this.logger.info('auth.mirror.synced', { sourceRuntimeId: runtime.id, name });
171
276
  const message = `${name} has been refreshed by ${sourceLabel} and synchronized to the other Codex homes.`;
172
277
  await Promise.allSettled(this.runtimes.map((target) => target.notify?.(message)));
278
+ await this.hooks.onSynced?.({
279
+ status: this.lastStatus,
280
+ record: {
281
+ ...record,
282
+ candidateName: name,
283
+ sourceRuntimeId: runtime.id,
284
+ sourceLabel,
285
+ },
286
+ });
173
287
  return true;
174
288
  }
175
289
  finally {
@@ -269,6 +383,25 @@ export class AuthCandidateMirror {
269
383
  const name = path.basename(finalPath);
270
384
  return isAuthCandidateName(name) ? name : null;
271
385
  }
386
+ async findNewestRecord(predicate) {
387
+ const newest = (await this.collectAuthRecords())
388
+ .filter(predicate)
389
+ .reduce((current, entry) => (!current || entry.record.lastRefreshMs > current.record.lastRefreshMs ? entry : current), null);
390
+ if (!newest)
391
+ return null;
392
+ return {
393
+ ...newest.record,
394
+ candidateName: newest.candidateName,
395
+ sourceRuntimeId: newest.runtimeId,
396
+ sourceLabel: this.runtimeLabel(newest.runtimeId),
397
+ };
398
+ }
399
+ runtimeLabel(runtimeId) {
400
+ if (runtimeId === 'canonical')
401
+ return 'canonical';
402
+ const runtime = this.runtimes.find((entry) => entry.id === runtimeId);
403
+ return runtime?.label ?? runtimeId;
404
+ }
272
405
  }
273
406
  export function isAuthCandidateName(name) {
274
407
  return name !== 'auth.json'
@@ -281,7 +414,7 @@ async function listAuthCandidateNames(dir) {
281
414
  .filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && isAuthCandidateName(entry.name))
282
415
  .map((entry) => entry.name);
283
416
  }
284
- async function readChatGptAuthRecord(filePath) {
417
+ export async function readChatGptAuthRecord(filePath) {
285
418
  try {
286
419
  const raw = await fs.readFile(filePath, 'utf8');
287
420
  const metadata = parseChatGptAuthMetadata(raw);
@@ -301,7 +434,7 @@ export async function readChatGptAuthMetadata(filePath) {
301
434
  return null;
302
435
  }
303
436
  }
304
- function parseChatGptAuthMetadata(raw) {
437
+ export function parseChatGptAuthMetadata(raw) {
305
438
  try {
306
439
  const parsed = JSON.parse(raw);
307
440
  const accountId = typeof parsed.tokens?.account_id === 'string' ? parsed.tokens.account_id : '';
package/dist/config.d.ts CHANGED
@@ -8,6 +8,8 @@ export declare const DEFAULT_LOCK_PATH: string;
8
8
  export declare const DEFAULT_CODEX_APP_SERVER_STATE_PATH: string;
9
9
  export declare const DEFAULT_CODEX_APP_SERVER_LOG_PATH: string;
10
10
  export declare const DEFAULT_CODEX_TELEGRAM_HOME: string;
11
+ export declare const DEFAULT_AUTH_SYNC_STATE_PATH: string;
12
+ export declare const DEFAULT_AUTH_SYNC_TEMP_DIR: string;
11
13
  export declare const DEFAULT_ENV_PATH: string;
12
14
  export declare function resolveEnvPath(): string;
13
15
  export declare function getLoadedEnvPath(): string | null;
@@ -51,6 +53,14 @@ export interface AppConfig {
51
53
  weixinMediaDir: string;
52
54
  /** Optional `SKRouteTag` header for some IDC deployments. */
53
55
  wxIlinkRouteTag: string | null;
56
+ authSyncEnabled: boolean;
57
+ authSyncTransport: 'telegram-private';
58
+ authSyncKey: string | null;
59
+ authSyncPeers: string[];
60
+ authSyncNodeId: string | null;
61
+ authSyncClusterId: string;
62
+ authSyncStatePath: string;
63
+ authSyncTempDir: string;
54
64
  }
55
65
  export declare function loadConfig(): AppConfig;
56
66
  export declare function selectDefaultRuntimeBotToken(configuredTokens: string[], legacyToken: string | null): string | null;
package/dist/config.js CHANGED
@@ -11,6 +11,8 @@ export const DEFAULT_LOCK_PATH = path.join(APP_HOME, 'runtime', 'bridge.lock');
11
11
  export const DEFAULT_CODEX_APP_SERVER_STATE_PATH = path.join(APP_HOME, 'runtime', 'codex-app-server.json');
12
12
  export const DEFAULT_CODEX_APP_SERVER_LOG_PATH = path.join(APP_HOME, 'logs', 'codex-app-server.log');
13
13
  export const DEFAULT_CODEX_TELEGRAM_HOME = path.join(APP_HOME, 'codex', 'telegram');
14
+ export const DEFAULT_AUTH_SYNC_STATE_PATH = path.join(APP_HOME, 'runtime', 'auth-sync.json');
15
+ export const DEFAULT_AUTH_SYNC_TEMP_DIR = path.join(APP_HOME, 'runtime', 'auth-sync');
14
16
  export const DEFAULT_ENV_PATH = path.join(APP_HOME, '.env');
15
17
  let envLoaded = false;
16
18
  let loadedEnvPath = null;
@@ -85,6 +87,14 @@ export function loadConfig() {
85
87
  weixinSyncBufDir: process.env.WEIXIN_SYNC_BUF_DIR || path.join(APP_HOME, 'weixin', 'sync-buf'),
86
88
  weixinMediaDir: process.env.WEIXIN_MEDIA_DIR || path.join(APP_HOME, 'weixin', 'media'),
87
89
  wxIlinkRouteTag: optional('WX_ILINK_ROUTE_TAG'),
90
+ authSyncEnabled: boolEnv('AUTH_SYNC_ENABLED', false),
91
+ authSyncTransport: 'telegram-private',
92
+ authSyncKey: optional('AUTH_SYNC_KEY'),
93
+ authSyncPeers: parseCommaSeparatedIds(process.env.AUTH_SYNC_PEERS),
94
+ authSyncNodeId: optional('AUTH_SYNC_NODE_ID'),
95
+ authSyncClusterId: process.env.AUTH_SYNC_CLUSTER_ID?.trim() || 'default',
96
+ authSyncStatePath: process.env.AUTH_SYNC_STATE_PATH || DEFAULT_AUTH_SYNC_STATE_PATH,
97
+ authSyncTempDir: process.env.AUTH_SYNC_TEMP_DIR || DEFAULT_AUTH_SYNC_TEMP_DIR,
88
98
  };
89
99
  ensureAppDirs(config);
90
100
  return config;
@@ -102,7 +112,11 @@ export function ensureAppDirs(config) {
102
112
  path.dirname(config.lockPath),
103
113
  path.dirname(config.codexAppServerStatePath),
104
114
  path.dirname(config.codexAppServerLogPath),
115
+ path.dirname(config.authSyncStatePath),
105
116
  ];
117
+ if (config.authSyncEnabled) {
118
+ dirs.push(config.authSyncTempDir);
119
+ }
106
120
  if (config.wxEnabled) {
107
121
  dirs.push(config.weixinAccountsDir, config.weixinSyncBufDir, config.weixinMediaDir);
108
122
  }
@@ -10,11 +10,26 @@ export interface CoreCoordinator {
10
10
  canSelfUpdate?: () => boolean;
11
11
  authCandidateUpdated?: (runtimeId: string, candidateName: string) => Promise<void>;
12
12
  recoverAuthCandidate?: (runtimeId: string, candidateName: string) => Promise<boolean>;
13
+ acquireAuthRefreshLease?: (reason: string) => Promise<{
14
+ ok: boolean;
15
+ leaseId: string | null;
16
+ reason?: string | null;
17
+ }>;
18
+ releaseAuthRefreshLease?: (leaseId: string | null) => Promise<void>;
19
+ getAuthSyncStatus?: () => RuntimeStatus['authSync'];
20
+ authSyncPushAll?: () => Promise<{
21
+ sent: number;
22
+ skipped: number;
23
+ }>;
24
+ authSyncTest?: () => Promise<{
25
+ sent: number;
26
+ }>;
13
27
  statusUpdated?: (status: RuntimeStatus) => void;
14
28
  getServiceStatus?: () => Promise<{
15
29
  bots: NonNullable<RuntimeStatus['bots']>;
16
30
  weixinRuntime?: RuntimeStatus['weixinRuntime'];
17
31
  authMirror?: RuntimeStatus['authMirror'];
32
+ authSync?: RuntimeStatus['authSync'];
18
33
  lastUpdate?: SelfUpdateStatus | null;
19
34
  }>;
20
35
  selfUpdateCompleted?: (status: SelfUpdateStatus) => void;
@@ -157,6 +172,10 @@ export declare class BridgeSessionCore {
157
172
  private registerActiveTurn;
158
173
  private createActiveTurnState;
159
174
  getCurrentAuthLabel(): Promise<string | null>;
175
+ validateExternalCodexAuthCandidate(candidateName: string, rawAuth: string, expectedAccountId: string): Promise<{
176
+ ok: boolean;
177
+ reason?: string | null;
178
+ }>;
160
179
  isIdleForServiceUpdate(): boolean;
161
180
  private hasLocalBlockingActivity;
162
181
  private authRuntimeId;
@@ -240,6 +259,7 @@ export declare class BridgeSessionCore {
240
259
  private formatSelfUpdateResult;
241
260
  private formatCodexUpdateResult;
242
261
  private handleAuthCommand;
262
+ private handleAuthSyncCommand;
243
263
  private handleAuthRefreshAllCommand;
244
264
  private handleAuthUseCommand;
245
265
  private handleAuthToggleCommand;
@@ -3,7 +3,7 @@ import fs from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { normalizeLocale, t } from '../i18n.js';
6
- import { readChatGptAuthMetadata } from '../auth/mirror.js';
6
+ import { parseChatGptAuthMetadata, readChatGptAuthMetadata } from '../auth/mirror.js';
7
7
  import { parseCommand } from './commands.js';
8
8
  import { buildAccessSettingsKeyboard, buildModelSettingsKeyboard, buildSetupPanelKeyboard, buildThreadListKeyboard, buildThreadsKeyboard, clampEffortToModel, formatAccessPresetLabel, formatActiveTurnMessageModeLabel, formatAccessSettingsMessage, formatApprovalPolicyLabel, formatCollaborationModeLabel, formatModelSettingsMessage, formatSandboxModeLabel, formatServiceTierStatusLabel, formatSetupPanelMessage, formatThreadContextSummary, formatThreadsMessage, formatWeixinAccessCopyPaste, formatWeixinModelCopyPaste, formatWeixinThreadsCopyPaste, formatWeixinWhereNavCopyPaste, formatWhereMessage, normalizeRequestedEffort, resolveCurrentModel, resolveActiveTurnMessageMode, resolveRequestedModel, } from './presentation.js';
9
9
  import { clampServiceTierToModel, resolveFastTierForModel } from './service_tier.js';
@@ -427,6 +427,16 @@ export class BridgeSessionCore {
427
427
  time: serviceStatus.authMirror.syncedAt,
428
428
  })
429
429
  : t(locale, 'status_auth_mirror_none'));
430
+ if (serviceStatus.authSync?.enabled) {
431
+ lines.push(t(locale, 'status_auth_sync', {
432
+ node: serviceStatus.authSync.nodeId ?? t(locale, 'unknown'),
433
+ peers: serviceStatus.authSync.peers.length,
434
+ pending: serviceStatus.authSync.pendingImports,
435
+ }));
436
+ if (serviceStatus.authSync.lastError) {
437
+ lines.push(t(locale, 'status_auth_sync_error', { value: serviceStatus.authSync.lastError }));
438
+ }
439
+ }
430
440
  if (serviceStatus.lastUpdate) {
431
441
  lines.push(t(locale, 'status_last_update', {
432
442
  from: serviceStatus.lastUpdate.fromVersion,
@@ -2619,6 +2629,55 @@ export class BridgeSessionCore {
2619
2629
  async getCurrentAuthLabel() {
2620
2630
  return (await this.listCodexAuthState()).currentLabel;
2621
2631
  }
2632
+ async validateExternalCodexAuthCandidate(candidateName, rawAuth, expectedAccountId) {
2633
+ if (!this.isIdleForServiceUpdate()) {
2634
+ return { ok: false, reason: 'runtime is not idle' };
2635
+ }
2636
+ const metadata = parseChatGptAuthMetadata(rawAuth);
2637
+ if (!metadata || metadata.accountId !== expectedAccountId) {
2638
+ return { ok: false, reason: 'remote auth account id mismatch' };
2639
+ }
2640
+ const state = await this.listCodexAuthState();
2641
+ const existing = state.candidates.find(candidate => candidate.name === candidateName) ?? null;
2642
+ if (existing) {
2643
+ const existingMetadata = await readChatGptAuthMetadata(existing.path);
2644
+ if (existingMetadata && existingMetadata.accountId !== expectedAccountId) {
2645
+ return { ok: false, reason: 'same candidate belongs to a different account' };
2646
+ }
2647
+ }
2648
+ const authStat = await fs.lstat(state.authPath).catch(() => null);
2649
+ const originalRegularAuth = authStat?.isFile()
2650
+ ? await fs.readFile(state.authPath, 'utf8').catch(() => null)
2651
+ : null;
2652
+ const tempPath = path.join(state.authDir, `.auth-sync-validate-${process.pid}-${Date.now()}.json`);
2653
+ try {
2654
+ await fs.writeFile(tempPath, rawAuth, { encoding: 'utf8', mode: 0o600 });
2655
+ await pointCodexAuthAtTarget(state.authDir, state.authPath, tempPath);
2656
+ this.pendingTurnErrors.clear();
2657
+ this.attachedThreads.clear();
2658
+ await this.app.restart();
2659
+ const account = await this.app.readAccount(false);
2660
+ const rateLimits = await this.app.readAccountRateLimits();
2661
+ if (!account || !rateLimits || !selectCodexRateLimitSnapshot(rateLimits)) {
2662
+ return { ok: false, reason: 'Codex did not validate ChatGPT usage for remote auth' };
2663
+ }
2664
+ return { ok: true };
2665
+ }
2666
+ catch (error) {
2667
+ return { ok: false, reason: formatUserError(error) };
2668
+ }
2669
+ finally {
2670
+ await restoreCodexAuthTarget(state.authDir, state.authPath, state.currentTargetPath, originalRegularAuth).catch((error) => {
2671
+ this.logger.warn('codex.auth_sync_restore_failed', { error: toErrorMeta(error) });
2672
+ });
2673
+ await fs.rm(tempPath, { force: true }).catch(() => undefined);
2674
+ this.pendingTurnErrors.clear();
2675
+ this.attachedThreads.clear();
2676
+ await this.app.restart().catch((error) => {
2677
+ this.logger.warn('codex.auth_sync_restart_restore_failed', { error: toErrorMeta(error) });
2678
+ });
2679
+ }
2680
+ }
2622
2681
  isIdleForServiceUpdate() {
2623
2682
  return this.activeTurns.size === 0
2624
2683
  && this.pendingApprovalMessages.size === 0
@@ -3894,6 +3953,10 @@ export class BridgeSessionCore {
3894
3953
  }
3895
3954
  async handleAuthCommand(scopeId, locale, args) {
3896
3955
  const action = args[0]?.toLowerCase() ?? 'list';
3956
+ if (action === 'sync') {
3957
+ await this.handleAuthSyncCommand(scopeId, locale, args.slice(1));
3958
+ return;
3959
+ }
3897
3960
  if (action === 'reload' || action === 'restart') {
3898
3961
  await this.handleAuthReloadCommand(scopeId, locale);
3899
3962
  return;
@@ -3934,6 +3997,39 @@ export class BridgeSessionCore {
3934
3997
  const messageId = await this.sendMessage(scopeId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(scopeId) !== null, record), authChoiceKeyboard(locale, record));
3935
3998
  record.messageId = messageId;
3936
3999
  }
4000
+ async handleAuthSyncCommand(scopeId, locale, args) {
4001
+ const action = args[0]?.toLowerCase() ?? 'status';
4002
+ if (action === 'status') {
4003
+ await this.sendMessage(scopeId, formatAuthSyncStatus(locale, this.coordinator?.getAuthSyncStatus?.() ?? null));
4004
+ return;
4005
+ }
4006
+ if (action === 'test') {
4007
+ const result = await this.coordinator?.authSyncTest?.();
4008
+ if (!result) {
4009
+ await this.sendMessage(scopeId, t(locale, 'auth_sync_disabled'));
4010
+ return;
4011
+ }
4012
+ await this.sendMessage(scopeId, t(locale, 'auth_sync_test_sent', { count: result.sent }));
4013
+ return;
4014
+ }
4015
+ if (action === 'push' && args[1]?.toLowerCase() === 'all') {
4016
+ if (!this.canRunGlobalAuthRefresh()) {
4017
+ await this.sendMessage(scopeId, t(locale, 'auth_sync_push_blocked_active'));
4018
+ return;
4019
+ }
4020
+ const result = await this.coordinator?.authSyncPushAll?.();
4021
+ if (!result) {
4022
+ await this.sendMessage(scopeId, t(locale, 'auth_sync_disabled'));
4023
+ return;
4024
+ }
4025
+ await this.sendMessage(scopeId, t(locale, 'auth_sync_push_done', {
4026
+ sent: result.sent,
4027
+ skipped: result.skipped,
4028
+ }));
4029
+ return;
4030
+ }
4031
+ await this.sendMessage(scopeId, t(locale, 'usage_auth_sync'));
4032
+ }
3937
4033
  async handleAuthRefreshAllCommand(scopeId, locale, confirmed = false) {
3938
4034
  if (!this.canRunGlobalAuthRefresh()) {
3939
4035
  await this.sendMessage(scopeId, t(locale, 'auth_refresh_all_blocked_active'));
@@ -3949,7 +4045,18 @@ export class BridgeSessionCore {
3949
4045
  return;
3950
4046
  }
3951
4047
  await this.sendMessage(scopeId, t(locale, 'auth_refresh_all_starting'));
3952
- const result = await this.refreshAllCodexAuthCandidates();
4048
+ const lease = await this.coordinator?.acquireAuthRefreshLease?.('auth refresh all');
4049
+ if (lease && !lease.ok) {
4050
+ await this.sendMessage(scopeId, t(locale, 'auth_refresh_all_lease_failed', { error: lease.reason ?? t(locale, 'unknown') }));
4051
+ return;
4052
+ }
4053
+ let result;
4054
+ try {
4055
+ result = await this.refreshAllCodexAuthCandidates();
4056
+ }
4057
+ finally {
4058
+ await this.coordinator?.releaseAuthRefreshLease?.(lease?.leaseId ?? null);
4059
+ }
3953
4060
  const state = await this.listCodexAuthState();
3954
4061
  await this.applySharedCodexAuthQuotaSnapshots(state);
3955
4062
  const record = createPendingAuthChoiceList(scopeId, state.candidates);
@@ -4460,7 +4567,20 @@ export class BridgeSessionCore {
4460
4567
  if (record.messageId !== null) {
4461
4568
  await this.editMessage(event.scopeId, record.messageId, t(locale, 'auth_refresh_all_starting'), []);
4462
4569
  }
4463
- const result = await this.refreshAllCodexAuthCandidates();
4570
+ const lease = await this.coordinator?.acquireAuthRefreshLease?.('auth refresh all');
4571
+ if (lease && !lease.ok) {
4572
+ if (record.messageId !== null) {
4573
+ await this.editMessage(event.scopeId, record.messageId, t(locale, 'auth_refresh_all_lease_failed', { error: lease.reason ?? t(locale, 'unknown') }), authChoiceKeyboard(locale, record));
4574
+ }
4575
+ return;
4576
+ }
4577
+ let result;
4578
+ try {
4579
+ result = await this.refreshAllCodexAuthCandidates();
4580
+ }
4581
+ finally {
4582
+ await this.coordinator?.releaseAuthRefreshLease?.(lease?.leaseId ?? null);
4583
+ }
4464
4584
  const state = await this.listCodexAuthState();
4465
4585
  await this.applySharedCodexAuthQuotaSnapshots(state);
4466
4586
  record.candidates = state.candidates;
@@ -4578,8 +4698,27 @@ export class BridgeSessionCore {
4578
4698
  this.authRotationInProgress = true;
4579
4699
  try {
4580
4700
  const failedTargets = rotation.retry?.failedAuthTargets ?? this.authRotationFailedTargets;
4581
- const selection = await this.selectNextCodexAuthCandidate(failedTargets);
4582
4701
  const locale = this.localeForChat(rotation.scopeId);
4702
+ const current = (await this.listCodexAuthState()).candidates.find(candidate => candidate.isCurrent) ?? null;
4703
+ if (current) {
4704
+ const recoveredCurrent = await this.recoverCodexAuthCandidate(current.name);
4705
+ if (recoveredCurrent) {
4706
+ await this.sendMessage(rotation.scopeId, t(locale, 'auth_auto_recovered_current', {
4707
+ value: current.name,
4708
+ error: formatShortStatusError(rotation.reason),
4709
+ }));
4710
+ this.pendingTurnErrors.clear();
4711
+ this.attachedThreads.clear();
4712
+ await this.app.restart();
4713
+ await this.syncCodexAuthCandidate(current.name);
4714
+ if (rotation.retry) {
4715
+ await this.retryTurnAfterAuthRotation(rotation.scopeId, locale, rotation.retry);
4716
+ return true;
4717
+ }
4718
+ return false;
4719
+ }
4720
+ }
4721
+ const selection = await this.selectNextCodexAuthCandidate(failedTargets);
4583
4722
  if (!selection) {
4584
4723
  await this.sendMessage(rotation.scopeId, t(locale, 'auth_auto_no_candidate', {
4585
4724
  error: formatShortStatusError(rotation.reason),
@@ -7971,6 +8110,36 @@ function formatAuthRefreshAllResult(locale, result) {
7971
8110
  }
7972
8111
  return lines.join('\n');
7973
8112
  }
8113
+ function formatAuthSyncStatus(locale, status) {
8114
+ if (!status?.enabled) {
8115
+ return t(locale, 'auth_sync_disabled');
8116
+ }
8117
+ const lines = [
8118
+ t(locale, 'auth_sync_status_title'),
8119
+ t(locale, 'auth_sync_status_node', { value: status.nodeId ?? t(locale, 'unknown') }),
8120
+ t(locale, 'auth_sync_status_peers', { value: status.peers.length === 0 ? t(locale, 'none') : status.peers.join(', ') }),
8121
+ t(locale, 'auth_sync_status_pending', { value: status.pendingImports }),
8122
+ t(locale, 'auth_sync_status_sent', { value: status.lastSentAt ?? t(locale, 'none') }),
8123
+ t(locale, 'auth_sync_status_received', { value: status.lastReceivedAt ?? t(locale, 'none') }),
8124
+ t(locale, 'auth_sync_status_imported', {
8125
+ value: status.lastImportedAt
8126
+ ? `${status.lastImportCandidate ?? t(locale, 'unknown')} @ ${status.lastImportedAt}`
8127
+ : t(locale, 'none'),
8128
+ }),
8129
+ t(locale, 'auth_sync_status_pull', {
8130
+ value: status.lastPullAt
8131
+ ? `${status.lastPullCandidate ?? t(locale, 'unknown')} @ ${status.lastPullAt}`
8132
+ : t(locale, 'none'),
8133
+ }),
8134
+ ];
8135
+ if (status.activeLeaseId) {
8136
+ lines.push(t(locale, 'auth_sync_status_lease', { value: status.activeLeaseId }));
8137
+ }
8138
+ if (status.lastError) {
8139
+ lines.push(t(locale, 'auth_sync_status_error', { value: status.lastError }));
8140
+ }
8141
+ return lines.join('\n');
8142
+ }
7974
8143
  function normalizeHelpUsageKey(name) {
7975
8144
  const normalized = name.toLowerCase();
7976
8145
  switch (normalized) {