@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.
@@ -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,6 +360,20 @@ 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;
@@ -417,6 +417,41 @@ The right-side `✅` / `⏸️` button controls whether the candidate participat
417
417
 
418
418
  OpenAI does not publish a fixed ChatGPT refresh-token lifetime or an old-token replay grace period. Codex refreshes automatically when an access token approaches expiry; when it cannot parse the access-token `exp`, current Codex uses a `last_refresh` fallback of about 8 days. The panel labels candidates without a refresh record in that interval as `not recently refreshed`, but this is only a maintenance hint. It does not prove expiry and does not trigger bulk keepalive refreshes. Do not use `/auth refresh all` as a routine keepalive command.
419
419
 
420
+ ### 6.4 Cross-Node Auth Sync
421
+
422
+ Cross-node auth sync is disabled by default. It is for multiple machines you control that share the same legally owned ChatGPT auth candidate pool, so a token refreshed by Codex on one node can be copied to the others. v1 uses Telegram Bot-to-Bot private messages to carry encrypted files, so it does not require public IPs or FRP. Enable Bot-to-Bot Communication Mode for the participating bots in BotFather first.
423
+
424
+ Example:
425
+
426
+ ```dotenv
427
+ AUTH_SYNC_ENABLED=true
428
+ AUTH_SYNC_KEY=<shared key with at least 32 bytes>
429
+ AUTH_SYNC_PEERS=@other_node_bot,@third_node_bot
430
+ AUTH_SYNC_CLUSTER_ID=my-codex-auth-pool
431
+ # Optional; FoxClaw generates and persists a local node id when omitted.
432
+ AUTH_SYNC_NODE_ID=workstation-a
433
+ ```
434
+
435
+ Safety boundaries:
436
+
437
+ - Telegram only carries ciphertext. Auth contents, candidate names, account ids, and `last_refresh` live inside the AES-256-GCM encrypted payload.
438
+ - FoxClaw only accepts sync files from bots listed in `AUTH_SYNC_PEERS`; wrong key, cluster, nonce, or payload validation never writes files.
439
+ - Remote imports wait for global local idleness, temporarily switch to the candidate for app-server usage validation, and only then write the candidate.
440
+ - A same-name candidate known to belong to a different account id is never overwritten.
441
+ - Cross-node recovery only pulls an already-held valid peer copy. It does not automatically rotate refresh tokens; if no peer has a usable copy, it stops and asks you to maintain auth on one node manually.
442
+
443
+ Dual-active behavior:
444
+
445
+ - Push: after local login, Codex automatic refresh, or `/auth refresh all` succeeds and passes local mirror validation, the newer candidate is encrypted and pushed to peers.
446
+ - Pull: before auth switch or reload, FoxClaw first searches local runtimes for a newer same-account candidate; if none is found, it asks peers for a newer same-name same-account copy.
447
+ - Lease: `/auth refresh all confirm` requests a cross-node refresh lease before rotating tokens. Any busy, denying, or non-responsive peer blocks the refresh.
448
+
449
+ Commands:
450
+
451
+ - `/auth sync status`: show node id, peers, recent sends/receives/imports, pending imports, and the latest error.
452
+ - `/auth sync test`: send an encrypted ping to verify peer config, shared key, and Bot-to-Bot private messages.
453
+ - `/auth sync push all`: manually broadcast all locally verified candidates without refreshing tokens.
454
+
420
455
  Equivalent commands:
421
456
 
422
457
  - `/auth` or `/auth list [keyword]`: show candidates, optionally filtered by filename.
@@ -428,6 +463,7 @@ Equivalent commands:
428
463
  - `/auth reload` or `/auth_reload`: restart app-server and reload the current `auth.json`.
429
464
  - `/auth refresh all`: show the refresh-token rotation risk confirmation.
430
465
  - `/auth refresh all confirm`: run Refresh all after accepting the token-rotation risk.
466
+ - `/auth sync status|test|push all`: inspect, test, or manually push cross-node auth sync.
431
467
 
432
468
  If the requesting bot runtime has active turns, pending approvals, pending user inputs, or MCP elicitations, FoxClaw refuses manual auth switching to avoid changing accounts mid-request; another idle bot is unaffected.
433
469
 
@@ -417,6 +417,41 @@ Candidates: 2
417
417
 
418
418
  OpenAI 没有公开 ChatGPT refresh token 的固定有效期或旧 token 重放宽限期。Codex 会在 access token 临近到期时自动刷新;如果 access token 里无法解析 `exp`,Codex 当前使用 `last_refresh` 超过约 8 天作为兜底刷新条件。面板把超过 8 天没有刷新记录的候选标为“长期未刷新”,但这只是维护提醒,不代表 refresh token 已经过期,也不会触发批量保活刷新。不要把 `/auth refresh all` 当作日常保活命令。
419
419
 
420
+ ### 6.4 跨节点 auth 同步
421
+
422
+ 跨节点 auth 同步默认关闭。它适合你在多台自己控制的机器上使用同一组合法 ChatGPT 账号候选,并希望某台机器上 Codex 自动刷新出的新 token 能同步到其他机器。v1 使用 Telegram Bot-to-Bot 私聊传输加密文件,不需要公网 IP 或 FRP;需要先在 BotFather 为参与同步的 bot 开启 Bot-to-Bot Communication Mode。
423
+
424
+ 配置示例:
425
+
426
+ ```dotenv
427
+ AUTH_SYNC_ENABLED=true
428
+ AUTH_SYNC_KEY=<至少32字节的共享密钥>
429
+ AUTH_SYNC_PEERS=@other_node_bot,@third_node_bot
430
+ AUTH_SYNC_CLUSTER_ID=my-codex-auth-pool
431
+ # 可选;不填时 FoxClaw 会生成并持久化本机 node id
432
+ AUTH_SYNC_NODE_ID=workstation-a
433
+ ```
434
+
435
+ 安全边界:
436
+
437
+ - Telegram 只承载密文;auth 文件内容、候选名、account id、`last_refresh` 都在 AES-256-GCM 加密 payload 内。
438
+ - 只接收 `AUTH_SYNC_PEERS` 中 peer bot 发来的同步文件;密钥、cluster、nonce 或 payload 校验失败时不会写盘。
439
+ - 远端导入必须等本机全局空闲,再临时切换到待验证 auth、重启 app-server、读取 usage 验证成功后才写入候选。
440
+ - 同名候选如果已知属于不同 account id,永远拒绝覆盖。
441
+ - 跨节点恢复只拉取 peer 已持有的有效副本,不会自动触发 refresh token 轮换;找不到有效副本时会停止,提示你在一个节点手动维护授权。
442
+
443
+ 双主动流程:
444
+
445
+ - push:本节点登录、Codex 自动刷新或 `/auth refresh all` 成功并通过本机镜像验证后,会主动把较新的候选加密推送给 peer。
446
+ - pull:本节点切换或重载 auth 前如果发现本地候选不是最新,会先查本机其他 runtime;仍找不到时,再向 peer 拉取同名同账号的较新副本。
447
+ - lease:执行会旋转 refresh token 的 `/auth refresh all confirm` 前,会向 peer 申请跨节点刷新锁。任一 peer 忙碌、拒绝或无响应都会阻止刷新。
448
+
449
+ 命令:
450
+
451
+ - `/auth sync status`:查看 node id、peer、最近收发、最近导入、待导入和最近错误。
452
+ - `/auth sync test`:发送加密 ping,确认 peer、共享密钥和 Bot-to-Bot 私聊可用。
453
+ - `/auth sync push all`:手动广播当前节点已验证的全部候选,不刷新 token。
454
+
420
455
  命令等价用法:
421
456
 
422
457
  - `/auth` 或 `/auth list [关键词]`:查看候选,可按文件名搜索。
@@ -428,6 +463,7 @@ OpenAI 没有公开 ChatGPT refresh token 的固定有效期或旧 token 重放
428
463
  - `/auth reload` 或 `/auth_reload`:重启 app-server,重新加载当前 `auth.json`。
429
464
  - `/auth refresh all`:显示 refresh token 轮换风险确认。
430
465
  - `/auth refresh all confirm`:接受 token 轮换风险后执行刷新全部。
466
+ - `/auth sync status|test|push all`:查看、测试或手动推送跨节点 auth 同步。
431
467
 
432
468
  切换 auth 时,如果当前 bot runtime 还有活跃 turn、待审批、待用户输入或 MCP elicitation,FoxClaw 会先拒绝切换,避免中途换号破坏正在进行的请求;另一个空闲 bot 不受影响。
433
469
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.4.13",
3
+ "version": "0.4.14",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
@@ -70,6 +70,8 @@ Telegram behavior:
70
70
  - `/auth` paginates large candidate inventories at 8 rows per page, supports all/enabled/attention filters and filename search, renders actual observed quota windows in text rows, uses compact two-number remaining quota labels on buttons, and omits the repeated `auth.json_` prefix from panel labels without renaming files
71
71
  - treat the `/auth` `not recently refreshed` state as a maintenance hint only: OpenAI does not publish a fixed ChatGPT refresh-token lifetime or replay grace period, and FoxClaw must not add routine bulk keepalive refreshes
72
72
  - `/auth refresh all` remains a command-only maintenance action, not a panel button; it requires explicit token-rotation risk confirmation, then force-refreshes every ChatGPT candidate through Codex `account/read refreshToken=true`, validates usage, mirrors successful candidates, and restores the original current auth while every runtime is globally idle
73
+ - optional cross-node auth sync uses Telegram Bot-to-Bot private messages with encrypted auth bundles; configure `AUTH_SYNC_ENABLED=true`, a shared `AUTH_SYNC_KEY`, and `AUTH_SYNC_PEERS`, then use `/auth sync status`, `/auth sync test`, and `/auth sync push all`; cross-node recovery pulls already-held valid peer copies and does not auto-refresh tokens
74
+ - when cross-node auth sync is enabled, `/auth refresh all confirm` must obtain a cross-node refresh lease before rotating refresh tokens; any busy, denying, or non-responsive peer blocks the refresh
73
75
  - if `TG_BOT_TOKEN` is also set to one exact token from `TG_BOT_TOKENS`, that bot uses the default/shared-terminal runtime instead of an isolated Telegram home
74
76
  - if Weixin is enabled alongside multiple Telegram bots, it remains on the default Codex runtime instead of borrowing a Telegram bot runtime
75
77
  - in a group with multiple configured bots, address a bot by mention, reply, or suffixed command such as `/status@botname`