@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.
package/dist/i18n.d.ts CHANGED
@@ -125,6 +125,8 @@ declare const MESSAGES: {
125
125
  readonly status_runtime_weixin: "- Weixin default runtime: connected {connected}, active turns {turns}";
126
126
  readonly status_auth_mirror_none: "Last auth mirror: none recorded";
127
127
  readonly status_auth_mirror_synced: "Last auth mirror: {candidate} from {source} at {time}";
128
+ readonly status_auth_sync: "Cross-node auth sync: node {node}, peers {peers}, pending imports {pending}";
129
+ readonly status_auth_sync_error: "Cross-node auth sync error: {value}";
128
130
  readonly status_last_update_none: "Last service update: none recorded";
129
131
  readonly status_last_update: "Last service update: {from} -> {to} at {time}";
130
132
  readonly status_last_codex_update: "Last Codex update: {value}";
@@ -138,7 +140,8 @@ declare const MESSAGES: {
138
140
  readonly auth_reload_restarting: "Restarting Codex app-server to reload auth...";
139
141
  readonly auth_reload_done: "Codex app-server restarted. Current auth has been reloaded.";
140
142
  readonly auth_reload_blocked_active: "Cannot reload Codex auth while a turn, approval, or question is active. Wait or use /interrupt first.";
141
- readonly usage_auth: "Usage: /auth [list [keyword]|filter <all|enabled|attention>|page <n>|use <n>|enable <n>|disable <n>|reload|refresh all [confirm]|add <name>]";
143
+ readonly usage_auth: "Usage: /auth [list [keyword]|filter <all|enabled|attention>|page <n>|use <n>|enable <n>|disable <n>|reload|refresh all [confirm]|sync <status|test|push all>|add <name>]";
144
+ readonly usage_auth_sync: "Usage: /auth sync <status|test|push all>";
142
145
  readonly usage_auth_add: "Usage: /auth add <name>. Use letters, numbers, dot, dash, or underscore.";
143
146
  readonly auth_list_title: "Codex auth files:";
144
147
  readonly auth_bot: "Bot runtime: {value}";
@@ -180,11 +183,13 @@ declare const MESSAGES: {
180
183
  readonly auth_refresh_all_cancelled: "Refresh all cancelled.";
181
184
  readonly auth_refresh_all_starting: "Refreshing all ChatGPT auth candidates. Every runtime must stay idle...";
182
185
  readonly auth_refresh_all_blocked_active: "Cannot refresh all auth candidates while any runtime, approval, input, login, or auth mirror write is active. Wait or use /interrupt first.";
186
+ readonly auth_refresh_all_lease_failed: "Cross-node refresh lock was not granted: {error}";
183
187
  readonly auth_refresh_all_done: "Auth refresh all complete: {refreshed} refreshed, {skipped} skipped, {failed} failed.";
184
188
  readonly auth_refresh_all_refreshed: "Refreshed: {value}";
185
189
  readonly auth_refresh_all_skipped: "Skipped non-ChatGPT/invalid candidates: {value}";
186
190
  readonly auth_refresh_all_failed: "Failed: {value}";
187
191
  readonly auth_auto_switching: "Codex auth problem detected ({error}). Switching: {from} -> {to}...";
192
+ readonly auth_auto_recovered_current: "Codex auth problem detected ({error}). Recovered a newer same-account credential for {value} and restarted Codex app-server.";
188
193
  readonly auth_auto_done: "Auto-switched Codex auth: {from} -> {to}.";
189
194
  readonly auth_auto_retrying: "Retrying the same request with the new auth...";
190
195
  readonly auth_auto_retry_thread_missing: "Auth was switched, but the original thread is no longer available ({threadId}). Retry was stopped to avoid creating duplicate sessions.";
@@ -197,6 +202,20 @@ declare const MESSAGES: {
197
202
  readonly auth_add_cancelled: "New auth login cancelled. Restored previous auth.";
198
203
  readonly auth_add_reverted: "Restored previous auth.";
199
204
  readonly auth_add_missing_file: "Login completed, but the new auth file was not created: {value}";
205
+ readonly auth_sync_disabled: "Cross-node auth sync is disabled.";
206
+ readonly auth_sync_test_sent: "Auth sync test ping sent to {count} peer(s).";
207
+ readonly auth_sync_push_blocked_active: "Cannot push auth sync while any runtime, approval, input, login, or auth mirror write is active.";
208
+ readonly auth_sync_push_done: "Auth sync push complete: sent {sent}, skipped {skipped}.";
209
+ readonly auth_sync_status_title: "Cross-node auth sync:";
210
+ readonly auth_sync_status_node: "Node: {value}";
211
+ readonly auth_sync_status_peers: "Peers: {value}";
212
+ readonly auth_sync_status_pending: "Pending imports: {value}";
213
+ readonly auth_sync_status_sent: "Last sent: {value}";
214
+ readonly auth_sync_status_received: "Last received: {value}";
215
+ readonly auth_sync_status_imported: "Last import: {value}";
216
+ readonly auth_sync_status_pull: "Last pull: {value}";
217
+ readonly auth_sync_status_lease: "Active refresh lease: {value}";
218
+ readonly auth_sync_status_error: "Last error: {value}";
200
219
  readonly button_login_device: "🔑 Login";
201
220
  readonly button_auth_reload: "🔄 Reload auth";
202
221
  readonly button_auth_refresh_all_confirm: "⚠️ Accept risk & refresh";
@@ -731,6 +750,8 @@ declare const MESSAGES: {
731
750
  readonly status_runtime_weixin: "- 微信默认运行时:连接 {connected},进行中回复 {turns}";
732
751
  readonly status_auth_mirror_none: "最近 auth 镜像:暂无记录";
733
752
  readonly status_auth_mirror_synced: "最近 auth 镜像:{candidate} 由 {source} 于 {time} 同步";
753
+ readonly status_auth_sync: "跨节点 auth 同步:节点 {node},peer {peers},待导入 {pending}";
754
+ readonly status_auth_sync_error: "跨节点 auth 同步错误:{value}";
734
755
  readonly status_last_update_none: "最近服务升级:暂无记录";
735
756
  readonly status_last_update: "最近服务升级:{from} -> {to}({time})";
736
757
  readonly status_last_codex_update: "最近 Codex 升级:{value}";
@@ -744,7 +765,8 @@ declare const MESSAGES: {
744
765
  readonly auth_reload_restarting: "正在重启 Codex app-server 以重新读取 auth...";
745
766
  readonly auth_reload_done: "Codex app-server 已重启,当前 auth 已重新读取。";
746
767
  readonly auth_reload_blocked_active: "当前有回复、审批或问题在进行中,不能重载 Codex auth。请先等待,或使用 /interrupt。";
747
- readonly usage_auth: "用法:/auth [list [关键词]|filter <all|enabled|attention>|page <页码>|use <编号>|enable <编号>|disable <编号>|reload|refresh all [confirm]|add <名称>]";
768
+ readonly usage_auth: "用法:/auth [list [关键词]|filter <all|enabled|attention>|page <页码>|use <编号>|enable <编号>|disable <编号>|reload|refresh all [confirm]|sync <status|test|push all>|add <名称>]";
769
+ readonly usage_auth_sync: "用法:/auth sync <status|test|push all>";
748
770
  readonly usage_auth_add: "用法:/auth add <名称>。名称只能包含字母、数字、点、短横线或下划线。";
749
771
  readonly auth_list_title: "Codex auth 文件:";
750
772
  readonly auth_bot: "Bot runtime:{value}";
@@ -786,11 +808,13 @@ declare const MESSAGES: {
786
808
  readonly auth_refresh_all_cancelled: "已取消刷新全部。";
787
809
  readonly auth_refresh_all_starting: "正在刷新全部 ChatGPT auth 候选。执行期间所有 runtime 必须保持空闲...";
788
810
  readonly auth_refresh_all_blocked_active: "当前有任一 runtime、审批、待输入、登录或 auth 镜像写入在进行中,不能刷新全部 auth。请先等待,或使用 /interrupt。";
811
+ readonly auth_refresh_all_lease_failed: "跨节点刷新锁未授予:{error}";
789
812
  readonly auth_refresh_all_done: "全部 auth 刷新完成:已刷新 {refreshed},已跳过 {skipped},失败 {failed}。";
790
813
  readonly auth_refresh_all_refreshed: "已刷新:{value}";
791
814
  readonly auth_refresh_all_skipped: "已跳过非 ChatGPT/无效候选:{value}";
792
815
  readonly auth_refresh_all_failed: "失败:{value}";
793
816
  readonly auth_auto_switching: "检测到 Codex auth 问题({error}),正在切换:{from} -> {to}...";
817
+ readonly auth_auto_recovered_current: "检测到 Codex auth 问题({error}),已为 {value} 恢复同账号较新凭据并重启 Codex app-server。";
794
818
  readonly auth_auto_done: "已自动切换 Codex auth:{from} -> {to}。";
795
819
  readonly auth_auto_retrying: "正在用新的 auth 重试同一条请求...";
796
820
  readonly auth_auto_retry_thread_missing: "Auth 已切换,但原线程已经不可用({threadId})。已停止重试,避免创建重复 session。";
@@ -803,6 +827,20 @@ declare const MESSAGES: {
803
827
  readonly auth_add_cancelled: "新 auth 登录已取消,已恢复之前的 auth。";
804
828
  readonly auth_add_reverted: "已恢复之前的 auth。";
805
829
  readonly auth_add_missing_file: "登录已完成,但没有创建新的 auth 文件:{value}";
830
+ readonly auth_sync_disabled: "跨节点 auth 同步未启用。";
831
+ readonly auth_sync_test_sent: "已向 {count} 个 peer 发送 auth sync 测试 ping。";
832
+ readonly auth_sync_push_blocked_active: "当前有任一 runtime、审批、待输入、登录或 auth 镜像写入在进行中,不能推送 auth 同步。";
833
+ readonly auth_sync_push_done: "auth 同步推送完成:已发送 {sent},已跳过 {skipped}。";
834
+ readonly auth_sync_status_title: "跨节点 auth 同步:";
835
+ readonly auth_sync_status_node: "节点:{value}";
836
+ readonly auth_sync_status_peers: "Peer:{value}";
837
+ readonly auth_sync_status_pending: "待导入:{value}";
838
+ readonly auth_sync_status_sent: "最近发送:{value}";
839
+ readonly auth_sync_status_received: "最近接收:{value}";
840
+ readonly auth_sync_status_imported: "最近导入:{value}";
841
+ readonly auth_sync_status_pull: "最近拉取:{value}";
842
+ readonly auth_sync_status_lease: "当前刷新锁:{value}";
843
+ readonly auth_sync_status_error: "最近错误:{value}";
806
844
  readonly button_login_device: "🔑 设备登录";
807
845
  readonly button_auth_reload: "🔄 重载 auth";
808
846
  readonly button_auth_refresh_all_confirm: "⚠️ 接受风险并刷新";
package/dist/i18n.js CHANGED
@@ -123,6 +123,8 @@ const MESSAGES = {
123
123
  status_runtime_weixin: '- Weixin default runtime: connected {connected}, active turns {turns}',
124
124
  status_auth_mirror_none: 'Last auth mirror: none recorded',
125
125
  status_auth_mirror_synced: 'Last auth mirror: {candidate} from {source} at {time}',
126
+ status_auth_sync: 'Cross-node auth sync: node {node}, peers {peers}, pending imports {pending}',
127
+ status_auth_sync_error: 'Cross-node auth sync error: {value}',
126
128
  status_last_update_none: 'Last service update: none recorded',
127
129
  status_last_update: 'Last service update: {from} -> {to} at {time}',
128
130
  status_last_codex_update: 'Last Codex update: {value}',
@@ -136,7 +138,8 @@ const MESSAGES = {
136
138
  auth_reload_restarting: 'Restarting Codex app-server to reload auth...',
137
139
  auth_reload_done: 'Codex app-server restarted. Current auth has been reloaded.',
138
140
  auth_reload_blocked_active: 'Cannot reload Codex auth while a turn, approval, or question is active. Wait or use /interrupt first.',
139
- usage_auth: 'Usage: /auth [list [keyword]|filter <all|enabled|attention>|page <n>|use <n>|enable <n>|disable <n>|reload|refresh all [confirm]|add <name>]',
141
+ usage_auth: 'Usage: /auth [list [keyword]|filter <all|enabled|attention>|page <n>|use <n>|enable <n>|disable <n>|reload|refresh all [confirm]|sync <status|test|push all>|add <name>]',
142
+ usage_auth_sync: 'Usage: /auth sync <status|test|push all>',
140
143
  usage_auth_add: 'Usage: /auth add <name>. Use letters, numbers, dot, dash, or underscore.',
141
144
  auth_list_title: 'Codex auth files:',
142
145
  auth_bot: 'Bot runtime: {value}',
@@ -178,11 +181,13 @@ const MESSAGES = {
178
181
  auth_refresh_all_cancelled: 'Refresh all cancelled.',
179
182
  auth_refresh_all_starting: 'Refreshing all ChatGPT auth candidates. Every runtime must stay idle...',
180
183
  auth_refresh_all_blocked_active: 'Cannot refresh all auth candidates while any runtime, approval, input, login, or auth mirror write is active. Wait or use /interrupt first.',
184
+ auth_refresh_all_lease_failed: 'Cross-node refresh lock was not granted: {error}',
181
185
  auth_refresh_all_done: 'Auth refresh all complete: {refreshed} refreshed, {skipped} skipped, {failed} failed.',
182
186
  auth_refresh_all_refreshed: 'Refreshed: {value}',
183
187
  auth_refresh_all_skipped: 'Skipped non-ChatGPT/invalid candidates: {value}',
184
188
  auth_refresh_all_failed: 'Failed: {value}',
185
189
  auth_auto_switching: 'Codex auth problem detected ({error}). Switching: {from} -> {to}...',
190
+ auth_auto_recovered_current: 'Codex auth problem detected ({error}). Recovered a newer same-account credential for {value} and restarted Codex app-server.',
186
191
  auth_auto_done: 'Auto-switched Codex auth: {from} -> {to}.',
187
192
  auth_auto_retrying: 'Retrying the same request with the new auth...',
188
193
  auth_auto_retry_thread_missing: 'Auth was switched, but the original thread is no longer available ({threadId}). Retry was stopped to avoid creating duplicate sessions.',
@@ -195,6 +200,20 @@ const MESSAGES = {
195
200
  auth_add_cancelled: 'New auth login cancelled. Restored previous auth.',
196
201
  auth_add_reverted: 'Restored previous auth.',
197
202
  auth_add_missing_file: 'Login completed, but the new auth file was not created: {value}',
203
+ auth_sync_disabled: 'Cross-node auth sync is disabled.',
204
+ auth_sync_test_sent: 'Auth sync test ping sent to {count} peer(s).',
205
+ auth_sync_push_blocked_active: 'Cannot push auth sync while any runtime, approval, input, login, or auth mirror write is active.',
206
+ auth_sync_push_done: 'Auth sync push complete: sent {sent}, skipped {skipped}.',
207
+ auth_sync_status_title: 'Cross-node auth sync:',
208
+ auth_sync_status_node: 'Node: {value}',
209
+ auth_sync_status_peers: 'Peers: {value}',
210
+ auth_sync_status_pending: 'Pending imports: {value}',
211
+ auth_sync_status_sent: 'Last sent: {value}',
212
+ auth_sync_status_received: 'Last received: {value}',
213
+ auth_sync_status_imported: 'Last import: {value}',
214
+ auth_sync_status_pull: 'Last pull: {value}',
215
+ auth_sync_status_lease: 'Active refresh lease: {value}',
216
+ auth_sync_status_error: 'Last error: {value}',
198
217
  button_login_device: '🔑 Login',
199
218
  button_auth_reload: '🔄 Reload auth',
200
219
  button_auth_refresh_all_confirm: '⚠️ Accept risk & refresh',
@@ -729,6 +748,8 @@ const MESSAGES = {
729
748
  status_runtime_weixin: '- 微信默认运行时:连接 {connected},进行中回复 {turns}',
730
749
  status_auth_mirror_none: '最近 auth 镜像:暂无记录',
731
750
  status_auth_mirror_synced: '最近 auth 镜像:{candidate} 由 {source} 于 {time} 同步',
751
+ status_auth_sync: '跨节点 auth 同步:节点 {node},peer {peers},待导入 {pending}',
752
+ status_auth_sync_error: '跨节点 auth 同步错误:{value}',
732
753
  status_last_update_none: '最近服务升级:暂无记录',
733
754
  status_last_update: '最近服务升级:{from} -> {to}({time})',
734
755
  status_last_codex_update: '最近 Codex 升级:{value}',
@@ -742,7 +763,8 @@ const MESSAGES = {
742
763
  auth_reload_restarting: '正在重启 Codex app-server 以重新读取 auth...',
743
764
  auth_reload_done: 'Codex app-server 已重启,当前 auth 已重新读取。',
744
765
  auth_reload_blocked_active: '当前有回复、审批或问题在进行中,不能重载 Codex auth。请先等待,或使用 /interrupt。',
745
- usage_auth: '用法:/auth [list [关键词]|filter <all|enabled|attention>|page <页码>|use <编号>|enable <编号>|disable <编号>|reload|refresh all [confirm]|add <名称>]',
766
+ usage_auth: '用法:/auth [list [关键词]|filter <all|enabled|attention>|page <页码>|use <编号>|enable <编号>|disable <编号>|reload|refresh all [confirm]|sync <status|test|push all>|add <名称>]',
767
+ usage_auth_sync: '用法:/auth sync <status|test|push all>',
746
768
  usage_auth_add: '用法:/auth add <名称>。名称只能包含字母、数字、点、短横线或下划线。',
747
769
  auth_list_title: 'Codex auth 文件:',
748
770
  auth_bot: 'Bot runtime:{value}',
@@ -784,11 +806,13 @@ const MESSAGES = {
784
806
  auth_refresh_all_cancelled: '已取消刷新全部。',
785
807
  auth_refresh_all_starting: '正在刷新全部 ChatGPT auth 候选。执行期间所有 runtime 必须保持空闲...',
786
808
  auth_refresh_all_blocked_active: '当前有任一 runtime、审批、待输入、登录或 auth 镜像写入在进行中,不能刷新全部 auth。请先等待,或使用 /interrupt。',
809
+ auth_refresh_all_lease_failed: '跨节点刷新锁未授予:{error}',
787
810
  auth_refresh_all_done: '全部 auth 刷新完成:已刷新 {refreshed},已跳过 {skipped},失败 {failed}。',
788
811
  auth_refresh_all_refreshed: '已刷新:{value}',
789
812
  auth_refresh_all_skipped: '已跳过非 ChatGPT/无效候选:{value}',
790
813
  auth_refresh_all_failed: '失败:{value}',
791
814
  auth_auto_switching: '检测到 Codex auth 问题({error}),正在切换:{from} -> {to}...',
815
+ auth_auto_recovered_current: '检测到 Codex auth 问题({error}),已为 {value} 恢复同账号较新凭据并重启 Codex app-server。',
792
816
  auth_auto_done: '已自动切换 Codex auth:{from} -> {to}。',
793
817
  auth_auto_retrying: '正在用新的 auth 重试同一条请求...',
794
818
  auth_auto_retry_thread_missing: 'Auth 已切换,但原线程已经不可用({threadId})。已停止重试,避免创建重复 session。',
@@ -801,6 +825,20 @@ const MESSAGES = {
801
825
  auth_add_cancelled: '新 auth 登录已取消,已恢复之前的 auth。',
802
826
  auth_add_reverted: '已恢复之前的 auth。',
803
827
  auth_add_missing_file: '登录已完成,但没有创建新的 auth 文件:{value}',
828
+ auth_sync_disabled: '跨节点 auth 同步未启用。',
829
+ auth_sync_test_sent: '已向 {count} 个 peer 发送 auth sync 测试 ping。',
830
+ auth_sync_push_blocked_active: '当前有任一 runtime、审批、待输入、登录或 auth 镜像写入在进行中,不能推送 auth 同步。',
831
+ auth_sync_push_done: 'auth 同步推送完成:已发送 {sent},已跳过 {skipped}。',
832
+ auth_sync_status_title: '跨节点 auth 同步:',
833
+ auth_sync_status_node: '节点:{value}',
834
+ auth_sync_status_peers: 'Peer:{value}',
835
+ auth_sync_status_pending: '待导入:{value}',
836
+ auth_sync_status_sent: '最近发送:{value}',
837
+ auth_sync_status_received: '最近接收:{value}',
838
+ auth_sync_status_imported: '最近导入:{value}',
839
+ auth_sync_status_pull: '最近拉取:{value}',
840
+ auth_sync_status_lease: '当前刷新锁:{value}',
841
+ auth_sync_status_error: '最近错误:{value}',
804
842
  button_login_device: '🔑 设备登录',
805
843
  button_auth_reload: '🔄 重载 auth',
806
844
  button_auth_refresh_all_confirm: '⚠️ 接受风险并刷新',
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;