@foxden-app/foxclaw 0.4.12 → 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;
@@ -238,7 +257,9 @@ export declare class BridgeSessionCore {
238
257
  private clearSelfUpdateStatusPoll;
239
258
  private pollSelfUpdateStatus;
240
259
  private formatSelfUpdateResult;
260
+ private formatCodexUpdateResult;
241
261
  private handleAuthCommand;
262
+ private handleAuthSyncCommand;
242
263
  private handleAuthRefreshAllCommand;
243
264
  private handleAuthUseCommand;
244
265
  private handleAuthToggleCommand;