@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.
package/dist/main.js CHANGED
@@ -141,7 +141,7 @@ Usage:
141
141
  foxclaw --help`);
142
142
  }
143
143
  async function runServeCli() {
144
- const [{ BridgeMessagingRouter }, { TelegramMessagingPort }, { WeixinChannelAdapter }, { WeixinMessagingPort }, { attachIlinkRuntimeFromBridgeLogger }, { loadWeixinAccount }, { Logger }, { BridgeStore }, { TelegramGateway }, { CodexAppClient }, { BridgeSessionCore }, { TelegramChannelAdapter }, { AuthCandidateMirror },] = await Promise.all([
144
+ const [{ BridgeMessagingRouter }, { TelegramMessagingPort }, { WeixinChannelAdapter }, { WeixinMessagingPort }, { attachIlinkRuntimeFromBridgeLogger }, { loadWeixinAccount }, { Logger }, { BridgeStore }, { TelegramGateway }, { CodexAppClient }, { BridgeSessionCore }, { TelegramChannelAdapter }, { AuthCandidateMirror }, { CrossNodeAuthSync },] = await Promise.all([
145
145
  import('./channels/bridge_messaging_router.js'),
146
146
  import('./channels/telegram/telegram_messaging_port.js'),
147
147
  import('./channels/weixin/weixin_channel_adapter.js'),
@@ -155,6 +155,7 @@ async function runServeCli() {
155
155
  import('./controller/controller.js'),
156
156
  import('./channels/telegram/telegram_channel_adapter.js'),
157
157
  import('./auth/mirror.js'),
158
+ import('./auth/cross_node_sync.js'),
158
159
  ]);
159
160
  const config = loadConfig();
160
161
  const logger = new Logger(config.logLevel, config.logPath);
@@ -166,6 +167,7 @@ async function runServeCli() {
166
167
  let activeTelegramAdapters = [];
167
168
  let managedApps = [];
168
169
  let activeAuthMirror = null;
170
+ let activeAuthSync = null;
169
171
  try {
170
172
  store = new BridgeStore(config.storePath);
171
173
  if (config.tgMultiBotMode) {
@@ -206,6 +208,7 @@ async function runServeCli() {
206
208
  const app = new CodexAppClient(runtimeConfig.codexCliBin, runtimeConfig.codexAppLaunchCmd, runtimeConfig.codexAppAutolaunch, runtimeConfig.codexAppServerStatePath, runtimeConfig.codexAppServerLogPath, logger, childEnv, sharedDefaultRuntime ? [] : ['cli_auth_credentials_store="file"']);
207
209
  seeds.push({ id, home, authDir, sharedDefaultRuntime, config: runtimeConfig, bot, app });
208
210
  }
211
+ let authSync = null;
209
212
  const mirror = new AuthCandidateMirror(canonicalAuthDir, seeds.map((runtime) => ({
210
213
  id: runtime.id,
211
214
  label: runtime.bot.username ? `@${runtime.bot.username}` : runtime.id,
@@ -217,9 +220,12 @@ async function runServeCli() {
217
220
  await runtime.bot.sendMessage(chatId, message);
218
221
  }
219
222
  },
220
- })), logger, path.join(APP_HOME, 'runtime', 'auth-mirror.json'));
223
+ })), logger, path.join(APP_HOME, 'runtime', 'auth-mirror.json'), {
224
+ onSynced: async (event) => {
225
+ await authSync?.publishCandidate(event.record.candidateName);
226
+ },
227
+ });
221
228
  await mirror.initialize();
222
- mirror.start();
223
229
  activeAuthMirror = mirror;
224
230
  managedApps = seeds.map((runtime) => runtime.app);
225
231
  const selfUpdater = createSelfUpdateRuntime({
@@ -271,15 +277,35 @@ async function runServeCli() {
271
277
  },
272
278
  } : {}),
273
279
  authMirror: mirror.getStatus(),
280
+ authSync: authSync?.getStatus() ?? null,
274
281
  lastUpdate: lastSelfUpdate,
275
282
  });
276
283
  };
284
+ const authSyncLocalIdle = () => runtimes.every((runtime) => runtime.core.isIdleForServiceUpdate())
285
+ && (!activeWeixinCore || activeWeixinCore.isIdleForServiceUpdate())
286
+ && mirror.isIdle();
277
287
  const coordinator = {
278
- canSelfUpdate: () => runtimes.every((runtime) => runtime.core.isIdleForServiceUpdate())
279
- && (!activeWeixinCore || activeWeixinCore.isIdleForServiceUpdate())
280
- && mirror.isIdle(),
288
+ canSelfUpdate: () => authSyncLocalIdle()
289
+ && (!authSync || authSync.isIdle()),
281
290
  authCandidateUpdated: (runtimeId, candidateName) => mirror.syncRuntimeCandidate(runtimeId, candidateName).then(() => undefined),
282
- recoverAuthCandidate: (runtimeId, candidateName) => mirror.recoverRuntimeCandidate(runtimeId, candidateName).then(recovery => recovery !== null),
291
+ recoverAuthCandidate: async (runtimeId, candidateName) => {
292
+ const local = await mirror.recoverRuntimeCandidate(runtimeId, candidateName);
293
+ if (local)
294
+ return true;
295
+ const current = await mirror.readRuntimeCandidate(runtimeId, candidateName)
296
+ ?? await mirror.readNewestCandidate(candidateName);
297
+ return await authSync?.requestRecovery(candidateName, {
298
+ accountId: current?.accountId ?? null,
299
+ lastRefreshMs: current?.lastRefreshMs ?? null,
300
+ }) ?? false;
301
+ },
302
+ acquireAuthRefreshLease: (reason) => authSync?.acquireRefreshLease(reason)
303
+ ?? Promise.resolve({ ok: true, leaseId: null }),
304
+ releaseAuthRefreshLease: (leaseId) => authSync?.releaseRefreshLease(leaseId)
305
+ ?? Promise.resolve(),
306
+ getAuthSyncStatus: () => authSync?.getStatus() ?? null,
307
+ authSyncPushAll: () => authSync?.pushAll() ?? Promise.resolve({ sent: 0, skipped: 0 }),
308
+ authSyncTest: () => authSync?.testPeers() ?? Promise.resolve({ sent: 0 }),
283
309
  statusUpdated: () => writeAggregateStatus(),
284
310
  getServiceStatus: async () => ({
285
311
  bots: await Promise.all(runtimes.map(async (runtime) => {
@@ -302,6 +328,7 @@ async function runServeCli() {
302
328
  },
303
329
  } : {}),
304
330
  authMirror: mirror.getStatus(),
331
+ authSync: authSync?.getStatus() ?? null,
305
332
  lastUpdate: lastSelfUpdate,
306
333
  }),
307
334
  selfUpdateCompleted: (status) => {
@@ -324,6 +351,33 @@ async function runServeCli() {
324
351
  weixinAdapter = new WeixinChannelAdapter(activeWeixinCore, store, config, logger);
325
352
  }
326
353
  activeTelegramAdapters = runtimes.map((runtime) => runtime.telegram);
354
+ if (config.authSyncEnabled) {
355
+ authSync = new CrossNodeAuthSync(buildAuthSyncConfig(config), logger, {
356
+ send: async (peer, envelope) => {
357
+ await seeds[0].bot.sendDocument(peer, `foxclaw-auth-sync-${Date.now()}.json`, Buffer.from(envelope, 'utf8'), 'FOXCLAW_AUTH_SYNC_V1');
358
+ },
359
+ }, {
360
+ readLocalCandidate: (candidateName) => mirror.readNewestCandidate(candidateName),
361
+ listLocalCandidates: () => mirror.listNewestCandidates(),
362
+ validateCandidate: async (candidateName, raw, expectedAccountId) => {
363
+ if (!authSyncLocalIdle()) {
364
+ return { ok: false, reason: 'runtime is not idle' };
365
+ }
366
+ const runtime = runtimes.find((entry) => entry.core.isIdleForServiceUpdate()) ?? runtimes[0] ?? null;
367
+ if (!runtime) {
368
+ return { ok: false, reason: 'no validation runtime is available' };
369
+ }
370
+ return runtime.core.validateExternalCodexAuthCandidate(candidateName, raw, expectedAccountId);
371
+ },
372
+ importCandidate: (candidateName, raw, source) => mirror.importExternalCandidate(candidateName, raw, source),
373
+ isIdle: authSyncLocalIdle,
374
+ });
375
+ await authSync.initialize();
376
+ activeAuthSync = authSync;
377
+ attachTelegramAuthSync(seeds[0].bot, authSync, config, logger);
378
+ authSync.start();
379
+ }
380
+ mirror.start();
327
381
  process.on('unhandledRejection', (error) => {
328
382
  logger.error('process.unhandled_rejection', { error: serializeError(error) });
329
383
  });
@@ -341,6 +395,7 @@ async function runServeCli() {
341
395
  logger.info('bridge.started', { bots: runtimes.map((runtime) => runtime.id) });
342
396
  const shutdown = async (signal) => {
343
397
  logger.info('bridge.shutting_down', { signal });
398
+ authSync?.stop();
344
399
  mirror.stop();
345
400
  await weixinAdapter?.stop();
346
401
  await activeWeixinCore?.stop();
@@ -372,7 +427,85 @@ async function runServeCli() {
372
427
  logPath: path.join(APP_HOME, 'logs', 'update.log'),
373
428
  codexCliBin: config.codexCliBin,
374
429
  });
375
- const core = new BridgeSessionCore(config, store, logger, bot, app, outbound, selfUpdater);
430
+ let singleAuthSync = null;
431
+ let singleMirror = null;
432
+ let core = null;
433
+ const singleAuthDir = config.codexAuthDir ?? config.codexHome ?? process.env.CODEX_AUTH_DIR ?? path.join(os.homedir(), '.codex');
434
+ const singleAuthSyncLocalIdle = () => Boolean(core?.isIdleForServiceUpdate())
435
+ && (!singleMirror || singleMirror.isIdle());
436
+ const singleCoordinator = config.authSyncEnabled ? {
437
+ canSelfUpdate: () => singleAuthSyncLocalIdle()
438
+ && (!singleAuthSync || singleAuthSync.isIdle()),
439
+ authCandidateUpdated: (runtimeId, candidateName) => singleMirror?.syncRuntimeCandidate(runtimeId, candidateName).then(() => undefined) ?? Promise.resolve(),
440
+ recoverAuthCandidate: async (runtimeId, candidateName) => {
441
+ const local = await singleMirror?.recoverRuntimeCandidate(runtimeId, candidateName) ?? null;
442
+ if (local)
443
+ return true;
444
+ const current = await singleMirror?.readRuntimeCandidate(runtimeId, candidateName)
445
+ ?? await singleMirror?.readNewestCandidate(candidateName)
446
+ ?? null;
447
+ return await singleAuthSync?.requestRecovery(candidateName, {
448
+ accountId: current?.accountId ?? null,
449
+ lastRefreshMs: current?.lastRefreshMs ?? null,
450
+ }) ?? false;
451
+ },
452
+ acquireAuthRefreshLease: (reason) => singleAuthSync?.acquireRefreshLease(reason)
453
+ ?? Promise.resolve({ ok: true, leaseId: null }),
454
+ releaseAuthRefreshLease: (leaseId) => singleAuthSync?.releaseRefreshLease(leaseId)
455
+ ?? Promise.resolve(),
456
+ getAuthSyncStatus: () => singleAuthSync?.getStatus() ?? null,
457
+ authSyncPushAll: () => singleAuthSync?.pushAll() ?? Promise.resolve({ sent: 0, skipped: 0 }),
458
+ authSyncTest: () => singleAuthSync?.testPeers() ?? Promise.resolve({ sent: 0 }),
459
+ statusUpdated: (status) => {
460
+ writeRuntimeStatus(config.statusPath, {
461
+ ...status,
462
+ authMirror: singleMirror?.getStatus() ?? null,
463
+ authSync: singleAuthSync?.getStatus() ?? null,
464
+ });
465
+ },
466
+ } : null;
467
+ if (config.authSyncEnabled) {
468
+ singleMirror = new AuthCandidateMirror(singleAuthDir, [{
469
+ id: 'default',
470
+ label: bot.username ? `@${bot.username}` : 'default',
471
+ authDir: singleAuthDir,
472
+ validate: async (context) => validateRefreshedAuthCandidate({
473
+ id: 'default',
474
+ authDir: singleAuthDir,
475
+ app,
476
+ }, context.candidateName),
477
+ }], logger, path.join(APP_HOME, 'runtime', 'auth-mirror.json'), {
478
+ onSynced: async (event) => {
479
+ await singleAuthSync?.publishCandidate(event.record.candidateName);
480
+ },
481
+ });
482
+ await singleMirror.initialize();
483
+ activeAuthMirror = singleMirror;
484
+ }
485
+ core = new BridgeSessionCore(config, store, logger, bot, app, outbound, selfUpdater, singleCoordinator);
486
+ if (config.authSyncEnabled && singleMirror) {
487
+ singleAuthSync = new CrossNodeAuthSync(buildAuthSyncConfig(config), logger, {
488
+ send: async (peer, envelope) => {
489
+ await bot.sendDocument(peer, `foxclaw-auth-sync-${Date.now()}.json`, Buffer.from(envelope, 'utf8'), 'FOXCLAW_AUTH_SYNC_V1');
490
+ },
491
+ }, {
492
+ readLocalCandidate: (candidateName) => singleMirror.readNewestCandidate(candidateName),
493
+ listLocalCandidates: () => singleMirror.listNewestCandidates(),
494
+ validateCandidate: async (candidateName, raw, expectedAccountId) => {
495
+ if (!singleAuthSyncLocalIdle()) {
496
+ return { ok: false, reason: 'runtime is not idle' };
497
+ }
498
+ return core.validateExternalCodexAuthCandidate(candidateName, raw, expectedAccountId);
499
+ },
500
+ importCandidate: (candidateName, raw, source) => singleMirror.importExternalCandidate(candidateName, raw, source),
501
+ isIdle: singleAuthSyncLocalIdle,
502
+ });
503
+ await singleAuthSync.initialize();
504
+ activeAuthSync = singleAuthSync;
505
+ attachTelegramAuthSync(bot, singleAuthSync, config, logger);
506
+ singleAuthSync.start();
507
+ singleMirror.start();
508
+ }
376
509
  const telegram = new TelegramChannelAdapter(core);
377
510
  managedApps = [app];
378
511
  activeTelegramAdapters = [telegram];
@@ -392,6 +525,8 @@ async function runServeCli() {
392
525
  logger.info('bridge.started', core.getRuntimeStatus());
393
526
  const shutdown = async (signal) => {
394
527
  logger.info('bridge.shutting_down', { signal });
528
+ singleAuthSync?.stop();
529
+ singleMirror?.stop();
395
530
  await weixinAdapter?.stop();
396
531
  await telegram.stop();
397
532
  writeRuntimeStatus(config.statusPath, {
@@ -419,6 +554,7 @@ async function runServeCli() {
419
554
  process.on('SIGTERM', () => void shutdown('SIGTERM'));
420
555
  }
421
556
  catch (error) {
557
+ activeAuthSync?.stop();
422
558
  activeAuthMirror?.stop();
423
559
  await weixinAdapter?.stop().catch(() => { });
424
560
  await activeWeixinCore?.stop().catch(() => { });
@@ -429,6 +565,54 @@ async function runServeCli() {
429
565
  throw error;
430
566
  }
431
567
  }
568
+ const AUTH_SYNC_TELEGRAM_CAPTION = 'FOXCLAW_AUTH_SYNC_V1';
569
+ function buildAuthSyncConfig(config) {
570
+ return {
571
+ enabled: config.authSyncEnabled,
572
+ transport: config.authSyncTransport,
573
+ key: config.authSyncKey,
574
+ peers: config.authSyncPeers,
575
+ nodeId: config.authSyncNodeId,
576
+ clusterId: config.authSyncClusterId,
577
+ statePath: config.authSyncStatePath,
578
+ tempDir: config.authSyncTempDir,
579
+ };
580
+ }
581
+ function attachTelegramAuthSync(bot, sync, config, logger) {
582
+ bot.on('peerDocument', (event) => {
583
+ void handleTelegramAuthSyncDocument(bot, sync, config, logger, event).catch((error) => {
584
+ logger.warn('auth.sync.telegram_document_failed', { error: serializeError(error) });
585
+ });
586
+ });
587
+ }
588
+ async function handleTelegramAuthSyncDocument(bot, sync, config, logger, event) {
589
+ const fileName = event.attachment.fileName ?? '';
590
+ if (event.text.trim() !== AUTH_SYNC_TELEGRAM_CAPTION && !fileName.startsWith('foxclaw-auth-sync-')) {
591
+ return;
592
+ }
593
+ const remoteFile = await bot.getFile(event.attachment.fileId);
594
+ if (!remoteFile.file_path) {
595
+ throw new Error('Telegram did not return file_path for auth sync document');
596
+ }
597
+ await fs.promises.mkdir(config.authSyncTempDir, { recursive: true, mode: 0o700 });
598
+ const destination = path.join(config.authSyncTempDir, `inbound-${process.pid}-${Date.now()}-${event.messageId}.json`);
599
+ try {
600
+ await bot.downloadResolvedFile(remoteFile.file_path, destination);
601
+ const raw = await fs.promises.readFile(destination, 'utf8');
602
+ const handled = await sync.handleIncomingEnvelope(raw, {
603
+ userId: event.userId,
604
+ username: event.username,
605
+ });
606
+ if (handled) {
607
+ logger.info('auth.sync.telegram_document_handled', {
608
+ peer: event.username ? `@${event.username}` : event.userId,
609
+ });
610
+ }
611
+ }
612
+ finally {
613
+ await fs.promises.rm(destination, { force: true }).catch(() => undefined);
614
+ }
615
+ }
432
616
  async function validateRefreshedAuthCandidate(runtime, candidateName) {
433
617
  const authPath = path.join(runtime.authDir, 'auth.json');
434
618
  const candidatePath = path.join(runtime.authDir, candidateName);
@@ -10,5 +10,11 @@ export interface TelegramRemoteFile {
10
10
  file_path?: string;
11
11
  }
12
12
  export declare function callTelegramApi<T>(botToken: string, method: string, body: Record<string, unknown>): Promise<TelegramApiResult<T>>;
13
+ export declare function callTelegramMultipartApi<T>(botToken: string, method: string, fields: Record<string, string>, files: Array<{
14
+ fieldName: string;
15
+ filename: string;
16
+ contents: Buffer;
17
+ contentType: string;
18
+ }>): Promise<TelegramApiResult<T>>;
13
19
  export declare function getTelegramFile(botToken: string, fileId: string): Promise<TelegramRemoteFile>;
14
20
  export declare function downloadTelegramFile(botToken: string, remoteFilePath: string, destinationPath: string): Promise<number>;
@@ -39,6 +39,53 @@ export async function callTelegramApi(botToken, method, body) {
39
39
  request.end();
40
40
  });
41
41
  }
42
+ export async function callTelegramMultipartApi(botToken, method, fields, files) {
43
+ const boundary = `foxclaw-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
44
+ const parts = [];
45
+ for (const [name, value] of Object.entries(fields)) {
46
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${escapeMultipartName(name)}"\r\n\r\n${value}\r\n`, 'utf8'));
47
+ }
48
+ for (const file of files) {
49
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${escapeMultipartName(file.fieldName)}"; filename="${escapeMultipartName(file.filename)}"\r\nContent-Type: ${file.contentType}\r\n\r\n`, 'utf8'));
50
+ parts.push(file.contents);
51
+ parts.push(Buffer.from('\r\n', 'utf8'));
52
+ }
53
+ parts.push(Buffer.from(`--${boundary}--\r\n`, 'utf8'));
54
+ const payload = Buffer.concat(parts);
55
+ return new Promise((resolve, reject) => {
56
+ const request = https.request({
57
+ host: API_HOST,
58
+ port: 443,
59
+ path: `/bot${botToken}/${method}`,
60
+ method: 'POST',
61
+ family: 4,
62
+ headers: {
63
+ 'content-type': `multipart/form-data; boundary=${boundary}`,
64
+ 'content-length': payload.length,
65
+ },
66
+ }, (response) => {
67
+ const chunks = [];
68
+ response.on('data', (chunk) => {
69
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
70
+ });
71
+ response.on('end', () => {
72
+ try {
73
+ const text = Buffer.concat(chunks).toString('utf8');
74
+ resolve(JSON.parse(text));
75
+ }
76
+ catch (error) {
77
+ reject(new Error(`Failed to parse Telegram response: ${String(error)}`));
78
+ }
79
+ });
80
+ });
81
+ request.on('error', reject);
82
+ request.setTimeout(20_000, () => {
83
+ request.destroy(new Error(`Telegram API request timed out for ${method}`));
84
+ });
85
+ request.write(payload);
86
+ request.end();
87
+ });
88
+ }
42
89
  export async function getTelegramFile(botToken, fileId) {
43
90
  const result = await callTelegramApi(botToken, 'getFile', { file_id: fileId });
44
91
  if (!result.ok || !result.result) {
@@ -87,3 +134,6 @@ export async function downloadTelegramFile(botToken, remoteFilePath, destination
87
134
  response?.destroy();
88
135
  }
89
136
  }
137
+ function escapeMultipartName(value) {
138
+ return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\r', '').replaceAll('\n', '');
139
+ }
@@ -17,6 +17,14 @@ export interface TelegramTextEvent {
17
17
  replyToBot: boolean;
18
18
  languageCode?: string;
19
19
  }
20
+ export interface TelegramPeerDocumentEvent {
21
+ chatId: string;
22
+ userId: string;
23
+ username: string | null;
24
+ messageId: number;
25
+ text: string;
26
+ attachment: TelegramInboundAttachment;
27
+ }
20
28
  export interface TelegramCallbackEvent {
21
29
  chatId: string;
22
30
  topicId: number | null;
@@ -53,6 +61,7 @@ export declare class TelegramGateway extends EventEmitter {
53
61
  text: string;
54
62
  callback_data: string;
55
63
  }>>, messageThreadId?: number | null): Promise<number>;
64
+ sendDocument(chatId: string, filename: string, contents: Buffer, caption?: string): Promise<number>;
56
65
  sendMessageDraft(chatId: string, draftId: number, text: string, messageThreadId?: number | null): Promise<void>;
57
66
  editMessage(chatId: string, messageId: number, text: string, inlineKeyboard?: Array<Array<{
58
67
  text: string;
@@ -1,6 +1,6 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import crypto from 'node:crypto';
3
- import { callTelegramApi, downloadTelegramFile, getTelegramFile } from './api.js';
3
+ import { callTelegramApi, callTelegramMultipartApi, downloadTelegramFile, getTelegramFile } from './api.js';
4
4
  import { getTelegramCommands } from '../i18n.js';
5
5
  import { toTelegramBridgeScopeId } from '../core/bridge_scope.js';
6
6
  import { createTelegramScopeId } from './scope.js';
@@ -56,6 +56,21 @@ export class TelegramGateway extends EventEmitter {
56
56
  async sendHtmlMessage(chatId, text, inlineKeyboard, messageThreadId) {
57
57
  return this.sendMessageWithOptions(chatId, text, inlineKeyboard, 'HTML', messageThreadId);
58
58
  }
59
+ async sendDocument(chatId, filename, contents, caption) {
60
+ const result = await callTelegramMultipartApi(this.botToken, 'sendDocument', {
61
+ chat_id: chatId,
62
+ ...(caption ? { caption } : {}),
63
+ }, [{
64
+ fieldName: 'document',
65
+ filename,
66
+ contents,
67
+ contentType: 'application/json',
68
+ }]);
69
+ if (!result.ok || !result.result) {
70
+ throw new Error(result.description || 'Failed to send Telegram document');
71
+ }
72
+ return result.result.message_id;
73
+ }
59
74
  async sendMessageDraft(chatId, draftId, text, messageThreadId) {
60
75
  const result = await callTelegramApi(this.botToken, 'sendMessageDraft', {
61
76
  chat_id: chatId,
@@ -197,8 +212,24 @@ export class TelegramGateway extends EventEmitter {
197
212
  }
198
213
  async handleUpdate(update) {
199
214
  if (update.message && update.message.from && this.isAllowedChat(update.message.chat)) {
200
- if (String(update.message.from.id) !== this.allowedUserId)
215
+ if (String(update.message.from.id) !== this.allowedUserId) {
216
+ if (update.message.chat.type === 'private') {
217
+ const text = update.message.text ?? update.message.caption ?? '';
218
+ const attachments = extractAttachments(update.message);
219
+ const document = attachments.find((attachment) => attachment.kind === 'document');
220
+ if (document) {
221
+ this.emit('peerDocument', {
222
+ chatId: String(update.message.chat.id),
223
+ userId: String(update.message.from.id),
224
+ username: update.message.from.username ?? null,
225
+ text,
226
+ messageId: update.message.message_id,
227
+ attachment: document,
228
+ });
229
+ }
230
+ }
201
231
  return;
232
+ }
202
233
  const attachments = extractAttachments(update.message);
203
234
  const text = update.message.text ?? update.message.caption ?? '';
204
235
  const topicId = update.message.message_thread_id ?? null;
package/dist/types.d.ts CHANGED
@@ -360,11 +360,27 @@ export interface RuntimeStatus {
360
360
  sourceLabel: string;
361
361
  syncedAt: string;
362
362
  } | null;
363
+ authSync?: {
364
+ enabled: boolean;
365
+ nodeId: string | null;
366
+ peers: string[];
367
+ pendingImports: number;
368
+ lastSentAt: string | null;
369
+ lastReceivedAt: string | null;
370
+ lastImportedAt: string | null;
371
+ lastImportCandidate: string | null;
372
+ lastPullAt: string | null;
373
+ lastPullCandidate: string | null;
374
+ lastError: string | null;
375
+ activeLeaseId: string | null;
376
+ } | null;
363
377
  lastUpdate?: {
364
378
  state: string;
365
379
  fromVersion: string;
366
380
  toVersion: string | null;
367
381
  codexUpdate?: string | null;
382
+ codexFromVersion?: string | null;
383
+ codexToVersion?: string | null;
368
384
  updatedAt: string;
369
385
  } | null;
370
386
  }
package/dist/update.d.ts CHANGED
@@ -7,6 +7,8 @@ export interface SelfUpdateStatus {
7
7
  fromVersion: string;
8
8
  toVersion: string | null;
9
9
  codexUpdate?: string | null;
10
+ codexFromVersion?: string | null;
11
+ codexToVersion?: string | null;
10
12
  error: string | null;
11
13
  updatedAt: string;
12
14
  }
package/dist/update.js CHANGED
@@ -132,6 +132,8 @@ export function readSelfUpdateStatus(statusFile) {
132
132
  fromVersion: parsed.fromVersion,
133
133
  toVersion: typeof parsed.toVersion === 'string' ? parsed.toVersion : null,
134
134
  ...(typeof parsed.codexUpdate === 'string' ? { codexUpdate: parsed.codexUpdate } : {}),
135
+ ...(typeof parsed.codexFromVersion === 'string' ? { codexFromVersion: parsed.codexFromVersion } : {}),
136
+ ...(typeof parsed.codexToVersion === 'string' ? { codexToVersion: parsed.codexToVersion } : {}),
135
137
  error: typeof parsed.error === 'string' ? parsed.error : null,
136
138
  updatedAt: parsed.updatedAt,
137
139
  };
@@ -161,6 +163,8 @@ export function createSelfUpdateRuntime(options) {
161
163
  fromVersion: options.version,
162
164
  toVersion: null,
163
165
  codexUpdate: null,
166
+ codexFromVersion: null,
167
+ codexToVersion: null,
164
168
  error: null,
165
169
  updatedAt: new Date().toISOString(),
166
170
  });
@@ -182,6 +186,8 @@ export function createSelfUpdateRuntime(options) {
182
186
  fromVersion: options.version,
183
187
  toVersion: null,
184
188
  codexUpdate: null,
189
+ codexFromVersion: null,
190
+ codexToVersion: null,
185
191
  error: formatError(error),
186
192
  updatedAt: new Date().toISOString(),
187
193
  });
@@ -205,7 +211,7 @@ export function performSelfUpdate(options) {
205
211
  let codexUpdate = null;
206
212
  try {
207
213
  codexUpdate = updateManagedCodexCli(options.codexCliBin ?? env.CODEX_CLI_BIN ?? '', options.nodePath, env);
208
- console.log(`[UPDATE] ${codexUpdate}`);
214
+ console.log(`[UPDATE] ${codexUpdate.message}`);
209
215
  const installer = resolveSelfUpdateInstaller(options.entryPoint, options.nodePath, fs.existsSync, env);
210
216
  const installerEnv = buildInstallerEnv(options.entryPoint, installer, env);
211
217
  console.log(`[UPDATE] Installing ${PACKAGE_SPEC} with ${installer.manager}...`);
@@ -236,22 +242,52 @@ export function performSelfUpdate(options) {
236
242
  }
237
243
  }
238
244
  function updateManagedCodexCli(codexCliBin, nodePath, env) {
245
+ const fromVersion = readCodexCliVersion(codexCliBin, env);
239
246
  if (!codexCliBin) {
240
- return 'Codex CLI update skipped: CODEX_CLI_BIN is not configured.';
247
+ return {
248
+ message: 'Codex CLI update skipped: CODEX_CLI_BIN is not configured.',
249
+ fromVersion,
250
+ toVersion: fromVersion,
251
+ };
241
252
  }
242
253
  const installer = resolveCodexUpdateInstaller(codexCliBin, nodePath, fs.existsSync, env);
243
254
  if (!installer) {
244
- return 'Codex CLI update skipped: configured installation is not a recognized global npm/pnpm package.';
255
+ return {
256
+ message: 'Codex CLI update skipped: configured installation is not a recognized global npm/pnpm package.',
257
+ fromVersion,
258
+ toVersion: fromVersion,
259
+ };
245
260
  }
246
261
  try {
247
262
  const installerEnv = buildInstallerEnv(fs.realpathSync(codexCliBin), installer, env);
248
263
  runInherited(installer.command, installer.installArgs, installerEnv);
249
- return `Codex CLI updated with ${installer.manager}.`;
264
+ return {
265
+ message: `Codex CLI updated with ${installer.manager}.`,
266
+ fromVersion,
267
+ toVersion: readCodexCliVersion(codexCliBin, installerEnv) ?? fromVersion,
268
+ };
250
269
  }
251
270
  catch (error) {
252
- return `Codex CLI update failed without blocking FoxClaw update: ${formatError(error)}`;
271
+ return {
272
+ message: `Codex CLI update failed without blocking FoxClaw update: ${formatError(error)}`,
273
+ fromVersion,
274
+ toVersion: readCodexCliVersion(codexCliBin, env) ?? fromVersion,
275
+ };
253
276
  }
254
277
  }
278
+ function readCodexCliVersion(codexCliBin, env) {
279
+ if (!codexCliBin) {
280
+ return null;
281
+ }
282
+ const result = spawnSync(codexCliBin, ['--version'], { encoding: 'utf8', env });
283
+ if (result.error || result.status !== 0) {
284
+ return null;
285
+ }
286
+ return parseCodexCliVersion(`${result.stdout}\n${result.stderr}`);
287
+ }
288
+ function parseCodexCliVersion(output) {
289
+ return output.match(/\b\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\b/)?.[0] ?? null;
290
+ }
255
291
  function executableCandidates(commandName, nodePath, env, preferred = []) {
256
292
  return [
257
293
  ...preferred,
@@ -329,7 +365,9 @@ function completeNotification(notificationFile, state, toVersion, codexUpdate, e
329
365
  ...pending,
330
366
  state,
331
367
  toVersion,
332
- codexUpdate,
368
+ codexUpdate: codexUpdate?.message ?? null,
369
+ codexFromVersion: codexUpdate?.fromVersion ?? null,
370
+ codexToVersion: codexUpdate?.toVersion ?? null,
333
371
  error,
334
372
  updatedAt: new Date().toISOString(),
335
373
  });