@foxden-app/foxclaw 0.3.18 → 0.4.0

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.
Files changed (39) hide show
  1. package/.env.example +8 -3
  2. package/README.md +17 -7
  3. package/README_EN.md +17 -7
  4. package/dist/auth/mirror.d.ts +38 -0
  5. package/dist/auth/mirror.js +259 -0
  6. package/dist/codex_app/client.d.ts +4 -1
  7. package/dist/codex_app/client.js +23 -4
  8. package/dist/config.d.ts +7 -0
  9. package/dist/config.js +18 -1
  10. package/dist/controller/controller.d.ts +24 -2
  11. package/dist/controller/controller.js +161 -35
  12. package/dist/core/bridge_scope.d.ts +5 -2
  13. package/dist/core/bridge_scope.js +7 -3
  14. package/dist/i18n.d.ts +20 -2
  15. package/dist/i18n.js +20 -2
  16. package/dist/main.js +198 -10
  17. package/dist/store/database.d.ts +4 -2
  18. package/dist/store/database.js +42 -6
  19. package/dist/telegram/addressing.d.ts +1 -0
  20. package/dist/telegram/addressing.js +3 -0
  21. package/dist/telegram/gateway.d.ts +4 -1
  22. package/dist/telegram/gateway.js +23 -5
  23. package/dist/types.d.ts +26 -0
  24. package/dist/update.d.ts +5 -0
  25. package/dist/update.js +93 -5
  26. package/docs/agent-assisted-install.md +7 -6
  27. package/docs/install-for-beginners.md +12 -4
  28. package/docs/troubleshooting.md +13 -1
  29. package/docs/user-manual.md +12 -12
  30. package/docs/zh/agent-assisted-install.md +7 -6
  31. package/docs/zh/foxclaw-skill.md +4 -2
  32. package/docs/zh/install-for-beginners.md +12 -4
  33. package/docs/zh/troubleshooting.md +13 -1
  34. package/docs/zh/user-manual.md +12 -12
  35. package/package.json +1 -1
  36. package/skills/foxclaw/SKILL.md +28 -20
  37. package/skills/foxclaw/references/telegram-setup.md +9 -6
  38. package/skills/foxclaw/scripts/bootstrap_host.py +11 -8
  39. package/skills/foxclaw/scripts/bootstrap_remote.py +8 -4
package/.env.example CHANGED
@@ -1,5 +1,8 @@
1
- # Required: one bot token per device
2
- TG_BOT_TOKEN=<telegram_bot_token>
1
+ # Required for new installs: one or more bot tokens for this host
2
+ # Comma-separated bot tokens. Each bot gets an independent Codex runtime/auth selection.
3
+ TG_BOT_TOKENS=<telegram_bot_token>
4
+ # Backward-compatible single-runtime setup:
5
+ # TG_BOT_TOKEN=<telegram_bot_token>
3
6
 
4
7
  # Required: only this Telegram user can control the bridge
5
8
  TG_ALLOWED_USER_ID=<telegram_user_id>
@@ -11,7 +14,9 @@ TG_ALLOWED_CHAT_ID=
11
14
  # Leave empty to use the whole allowed group as the default scope
12
15
  TG_ALLOWED_TOPIC_ID=
13
16
 
14
- # Optional Codex Desktop bridge settings
17
+ # Optional Codex Desktop bridge settings.
18
+ # In TG_BOT_TOKENS mode isolated Telegram runtimes start app-server directly and
19
+ # do not auto-launch Codex Desktop; this remains applicable to the default/Weixin runtime.
15
20
  CODEX_APP_AUTOLAUNCH=true
16
21
  CODEX_APP_LAUNCH_CMD=codex app
17
22
  # Optional: persist the managed app-server pid/port and collect its stdout/stderr
package/README.md CHANGED
@@ -40,7 +40,7 @@ FoxClaw(狸爪)的目标很直接:让你用手机控制本机的 Codex,
40
40
  - Git、Node、`.env` 都玩得转?直接往下看快速设置。
41
41
  - 卡住了?看 [故障排查](./docs/zh/troubleshooting.md)。
42
42
 
43
- 最低要求:一个 Telegram bot token、你的 Telegram 数字用户 ID、Node.js 24+、一份已登录的 `codex` CLI。首次安装大约 10–20 分钟。
43
+ 最低要求:一个或多个 Telegram bot token、你的 Telegram 数字用户 ID、Node.js 24+、一份已登录的 `codex` CLI。首次安装大约 10–20 分钟。新安装请使用 `TG_BOT_TOKENS`;`TG_BOT_TOKEN` 只用于兼容旧的单 runtime 配置。
44
44
 
45
45
  **30 秒体验**:启动 FoxClaw 后,给你的 Telegram 机器人发一句 `List files in DEFAULT_CWD`。Codex 会在本地检查那个目录,然后把结果发回 Telegram。
46
46
 
@@ -75,7 +75,7 @@ foxclaw start
75
75
  跑 `doctor` 或 `start` 之前先把 `.env` 填好。私聊模式最小配置:
76
76
 
77
77
  ```dotenv
78
- TG_BOT_TOKEN=123456:telegram-token
78
+ TG_BOT_TOKENS=123456:telegram-token
79
79
  TG_ALLOWED_USER_ID=123456789
80
80
  DEFAULT_CWD=/absolute/path/to/workspace
81
81
  DEFAULT_APPROVAL_POLICY=on-request
@@ -84,7 +84,7 @@ DEFAULT_SANDBOX_MODE=workspace-write
84
84
 
85
85
  配置文件默认在 `~/.foxclaw/.env`。想放别处的话设 `FOXCLAW_ENV=/path/to/.env`。
86
86
 
87
- `foxclaw start` 会自动检查环境并安装/重启后台服务。后续升级直接运行 `foxclaw update`,它会沿用当前的 npm/pnpm 全局安装方式,完成安装、自检和服务重启。
87
+ `foxclaw start` 会自动检查环境并安装/重启后台服务。后续升级直接运行 `foxclaw update`,它会沿用当前的 npm/pnpm 全局安装方式,尝试同步升级 npm/pnpm 安装的 Codex CLI,再完成安装、自检和服务重启。
88
88
 
89
89
  FoxClaw 只响应 `TG_ALLOWED_USER_ID` 的消息——把机器人拉进群不代表群里所有人都能用。
90
90
 
@@ -93,7 +93,7 @@ FoxClaw 只响应 `TG_ALLOWED_USER_ID` 的消息——把机器人拉进群不
93
93
 
94
94
  **核心能力:**
95
95
  - 通过 Telegram 私聊、群组、话题控制本地 Codex
96
- - 可选微信/iLink 通道,复用同一套桥接核心
96
+ - 可选微信/iLink 通道;在多 Telegram bot 模式下仍使用原默认 Codex runtime,不与隔离 bot 会话混用
97
97
  - 手机上完整管理 Codex 线程生命周期:创建、重命名、归档、fork、回滚、compact、review、diff
98
98
  - 命令、文件变更、细粒度权限审批的内联按钮——手机上一键审批
99
99
  - MCP elicitation 卡片——工具在 turn 中提出结构化问题时展示
@@ -111,6 +111,7 @@ FoxClaw 只响应 `TG_ALLOWED_USER_ID` 的消息——把机器人拉进群不
111
111
  **可靠性:**
112
112
  - SQLite 持久化:绑定、offset、审批、待处理提示、审计日志
113
113
  - 单实例进程锁,防止同一 bot token 重复 polling
114
+ - `TG_BOT_TOKENS` 支持一台机器同时运行多个 Telegram bot;每个 bot 使用独立 Codex app-server、会话和 `/auth` 选择
114
115
 
115
116
  </details>
116
117
 
@@ -178,7 +179,7 @@ FoxClaw 最初基于 `Gan-Xing/telegram-codex-app-bridge` fork 演进而来,
178
179
 
179
180
  ## Telegram 设置
180
181
 
181
- 1. 用 `@BotFather` 创建机器人,token 填入 `TG_BOT_TOKEN`。
182
+ 1. 用 `@BotFather` 创建一个或多个机器人,token 以逗号分隔填入 `TG_BOT_TOKENS`。
182
183
  2. 拿到你的 Telegram 数字用户 ID,填入 `TG_ALLOWED_USER_ID`。
183
184
  3. `foxclaw start` 启动。
184
185
  4. 打开和机器人的私聊,发 `/help`。
@@ -194,6 +195,15 @@ TG_ALLOWED_TOPIC_ID=42
194
195
  - 只填 `TG_ALLOWED_CHAT_ID` → 允许一个群组作为默认会话范围。
195
196
  - 两个都填 → 绑定到某个话题。
196
197
  - 配了群组后,`TG_ALLOWED_USER_ID` 的私聊依然可用。
198
+ - 配置多个 bot 时,同一个授权群内只有 `@botname` 命令、mention 或回复该 bot 的消息会被它处理,避免多个 Codex 会话同时响应。
199
+
200
+ 多 bot 并行示例:
201
+
202
+ ```dotenv
203
+ TG_BOT_TOKENS=123456:token_a,234567:token_b
204
+ ```
205
+
206
+ FoxClaw 仍然只运行一个系统服务,但会为每个 bot 启动独立 `codex app-server` 和独立 `CODEX_HOME`。因此 A 私聊运行 turn 时,B 私聊仍可独立切换自己的 `/auth`。候选凭据由 FoxClaw 在登录或刷新验证后镜像同步,各 bot 的当前选择互不影响。每个 bot 首次私聊发送 `/help` 和 `/status`;`/auth` 会标明正在操作的 bot runtime,`/status` 会列出全部 bot 的连接、当前 auth 和活动 turn 摘要。
197
207
 
198
208
  **怎么找群组和话题 ID:**
199
209
 
@@ -229,7 +239,7 @@ CODEX_APP_SYNC_ON_OPEN=true
229
239
  CODEX_APP_SYNC_ON_TURN_COMPLETE=false
230
240
  ```
231
241
 
232
- FoxClaw 会把 `codex app-server` 作为 detached 子进程启动,记录其 pid 和端口。重启时如果进程还活着就直接重连,否则拉起新进程。`/auth_reload` 和认证切换会重启 app-server 以重新加载 `auth.json`。
242
+ FoxClaw 会把 `codex app-server` 作为 detached 子进程启动,记录其 pid 和端口。使用 `TG_BOT_TOKENS` 时,每个 bot 都有自己的 app-server Codex home,并在该隔离 runtime 内强制使用文件凭据存储;隔离 bot 不自动拉起 Codex Desktop,避免多个新 home 同时初始化桌面状态。重启时如果对应进程还活着就直接重连,否则拉起新进程。`/auth_reload` 和认证切换只重启发起操作的 bot runtime。
233
243
 
234
244
  一般不需要手动固定 app-server 端口。
235
245
 
@@ -274,7 +284,7 @@ WX_ALLOWED_ILINK_USER_IDS=
274
284
  foxclaw weixin-login
275
285
  ```
276
286
 
277
- 微信运行时文件在 `~/.foxclaw/weixin`。
287
+ 微信运行时文件在 `~/.foxclaw/weixin`。启用 `TG_BOT_TOKENS` 时,微信继续连接默认 Codex runtime 与原 home,不会查看或恢复隔离 Telegram bot 的线程。
278
288
 
279
289
  ## Codex Skill
280
290
 
package/README_EN.md CHANGED
@@ -40,7 +40,7 @@ FoxClaw is more than message forwarding. It provides Telegram panels for Codex w
40
40
  - Already comfortable with Git, Node, and `.env` files? Use the quick setup below.
41
41
  - Something failed? Check [Troubleshooting](./docs/troubleshooting.md).
42
42
 
43
- The minimum install needs only a Telegram bot token, your numeric Telegram user id, Node.js 24+, and a logged-in `codex` CLI. A first install usually takes 10–20 minutes.
43
+ The minimum install needs one or more Telegram bot tokens, your numeric Telegram user id, Node.js 24+, and a logged-in `codex` CLI. A first install usually takes 10–20 minutes. New installs should use `TG_BOT_TOKENS`; `TG_BOT_TOKEN` exists only for legacy single-runtime compatibility.
44
44
 
45
45
  **30-second demo**: after FoxClaw is running, send `List files in DEFAULT_CWD` to your Telegram bot. FoxClaw asks local Codex to inspect that folder on your computer and sends the answer back to Telegram.
46
46
 
@@ -75,7 +75,7 @@ foxclaw start
75
75
  Fill `.env` before running `doctor` or `start`. Minimum private-chat config:
76
76
 
77
77
  ```dotenv
78
- TG_BOT_TOKEN=123456:telegram-token
78
+ TG_BOT_TOKENS=123456:telegram-token
79
79
  TG_ALLOWED_USER_ID=123456789
80
80
  DEFAULT_CWD=/absolute/path/to/workspace
81
81
  DEFAULT_APPROVAL_POLICY=on-request
@@ -84,7 +84,7 @@ DEFAULT_SANDBOX_MODE=workspace-write
84
84
 
85
85
  The default config file is `~/.foxclaw/.env`. Set `FOXCLAW_ENV=/path/to/.env` if you want to keep it somewhere else.
86
86
 
87
- `foxclaw start` runs checks and installs or restarts the background service. For later upgrades, run `foxclaw update`; it preserves the current npm/pnpm global-install method, runs checks, and restarts the service.
87
+ `foxclaw start` runs checks and installs or restarts the background service. For later upgrades, run `foxclaw update`; it preserves the current npm/pnpm global-install method, attempts to update an npm/pnpm-managed Codex CLI, runs checks, and restarts the service.
88
88
 
89
89
  FoxClaw accepts messages only from `TG_ALLOWED_USER_ID`. Putting the bot in a group does not make it available to every group member.
90
90
 
@@ -93,7 +93,7 @@ FoxClaw accepts messages only from `TG_ALLOWED_USER_ID`. Putting the bot in a gr
93
93
 
94
94
  **Core capabilities:**
95
95
  - Telegram private chat, group, and topic control for your local Codex
96
- - Optional Weixin/iLink channel sharing the same bridge core
96
+ - Optional Weixin/iLink channel; in multi-Telegram mode it stays on the original default Codex runtime rather than sharing an isolated bot session
97
97
  - Full thread lifecycle management from mobile: create, rename, archive, fork, rollback, compact, review, diff
98
98
  - Inline approval buttons for commands, file changes, and granular permissions — one tap to approve
99
99
  - MCP elicitation cards for structured questions raised by tools during a turn
@@ -111,6 +111,7 @@ FoxClaw accepts messages only from `TG_ALLOWED_USER_ID`. Putting the bot in a gr
111
111
  **Reliability:**
112
112
  - SQLite persistence for bindings, offsets, approvals, pending input prompts, and audit logs
113
113
  - Single-instance process lock to prevent duplicate Telegram polling on the same bot token
114
+ - `TG_BOT_TOKENS` runs multiple Telegram bots on one host with independent Codex app-servers, sessions, and `/auth` selections
114
115
 
115
116
  </details>
116
117
 
@@ -178,7 +179,7 @@ FoxClaw originally evolved from a fork of `Gan-Xing/telegram-codex-app-bridge` a
178
179
 
179
180
  ## Telegram Setup
180
181
 
181
- 1. Create a bot with `@BotFather` and copy the token into `TG_BOT_TOKEN`.
182
+ 1. Create one or more bots with `@BotFather` and put their comma-separated tokens into `TG_BOT_TOKENS`.
182
183
  2. Get your Telegram numeric user id and place it into `TG_ALLOWED_USER_ID`.
183
184
  3. Start FoxClaw with `foxclaw start`.
184
185
  4. Open a private chat with the bot and send `/help`.
@@ -194,6 +195,15 @@ TG_ALLOWED_TOPIC_ID=42
194
195
  - Set `TG_ALLOWED_CHAT_ID` only to allow one group as the default conversation scope.
195
196
  - Set both `TG_ALLOWED_CHAT_ID` and `TG_ALLOWED_TOPIC_ID` to bind one topic as the default scope.
196
197
  - Private chat remains available for `TG_ALLOWED_USER_ID` even when a group is configured.
198
+ - With multiple bots, a configured group handles only `@botname` commands, mentions, or replies addressed to each bot, preventing duplicate Codex responses.
199
+
200
+ Parallel bot example:
201
+
202
+ ```dotenv
203
+ TG_BOT_TOKENS=123456:token_a,234567:token_b
204
+ ```
205
+
206
+ FoxClaw remains one system service, but starts an independent `codex app-server` and `CODEX_HOME` for each bot. While bot A is running a turn, bot B can switch its own `/auth` selection. Candidate credentials are mirrored only after validated login or refresh; current selections remain independent. Send `/help` and `/status` in a private chat with each bot after installation; `/auth` names the runtime being managed, and `/status` summarizes every bot's connection, selected auth, and active turns.
197
207
 
198
208
  **How to find group and topic IDs:**
199
209
 
@@ -229,7 +239,7 @@ CODEX_APP_SYNC_ON_OPEN=true
229
239
  CODEX_APP_SYNC_ON_TURN_COMPLETE=false
230
240
  ```
231
241
 
232
- FoxClaw starts `codex app-server` as a detached, bridge-managed process and records its pid and port. On restart, it reconnects to the recorded app-server if that process is still alive; otherwise it starts a new one. `/auth_reload` and auth switching restart the managed app-server so the current `auth.json` is reloaded.
242
+ FoxClaw starts `codex app-server` as a detached, bridge-managed process and records its pid and port. With `TG_BOT_TOKENS`, each bot has its own app-server and Codex home, and its isolated runtime is forced to use file-backed credentials. Isolated Telegram runtimes do not auto-launch Codex Desktop, avoiding simultaneous desktop-state initialization for fresh homes. On restart, FoxClaw reconnects to each live recorded app-server or starts a replacement. `/auth_reload` and auth switching restart only the requesting bot runtime.
233
243
 
234
244
  No static Codex app-server port is required in normal installs.
235
245
 
@@ -274,7 +284,7 @@ Run the QR login helper once after building:
274
284
  foxclaw weixin-login
275
285
  ```
276
286
 
277
- Weixin runtime files default to `~/.foxclaw/weixin`.
287
+ Weixin runtime files default to `~/.foxclaw/weixin`. When `TG_BOT_TOKENS` is enabled, Weixin continues to use the default Codex runtime and original home; it does not inspect or resume isolated Telegram bot threads.
278
288
 
279
289
  ## Codex Skill
280
290
 
@@ -0,0 +1,38 @@
1
+ import type { Logger } from '../logger.js';
2
+ export interface AuthMirrorRuntime {
3
+ id: string;
4
+ label?: string;
5
+ authDir: string;
6
+ notify?: (message: string) => Promise<void>;
7
+ }
8
+ export interface AuthMirrorStatus {
9
+ candidateName: string;
10
+ sourceRuntimeId: string;
11
+ sourceLabel: string;
12
+ syncedAt: string;
13
+ }
14
+ export declare class AuthCandidateMirror {
15
+ private readonly canonicalDir;
16
+ private readonly runtimes;
17
+ private readonly logger;
18
+ private readonly statusPath;
19
+ private timer;
20
+ private readonly lastSyncedRefresh;
21
+ private activeOperations;
22
+ private lastStatus;
23
+ constructor(canonicalDir: string, runtimes: AuthMirrorRuntime[], logger: Logger, statusPath?: string | null);
24
+ initialize(): Promise<void>;
25
+ start(): void;
26
+ stop(): void;
27
+ isIdle(): boolean;
28
+ getStatus(): AuthMirrorStatus | null;
29
+ syncRuntimeCandidate(runtimeId: string, candidateName: string): Promise<boolean>;
30
+ private scan;
31
+ private propagateValidatedCandidate;
32
+ private withActivity;
33
+ private ensureCanonicalDefaultCandidate;
34
+ private collectCandidateNames;
35
+ private reconcileCandidateAtStartup;
36
+ private resolveCanonicalCurrentCandidate;
37
+ }
38
+ export declare function isAuthCandidateName(name: string): boolean;
@@ -0,0 +1,259 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ const AUTH_SCAN_INTERVAL_MS = 5_000;
4
+ export class AuthCandidateMirror {
5
+ canonicalDir;
6
+ runtimes;
7
+ logger;
8
+ statusPath;
9
+ timer = null;
10
+ lastSyncedRefresh = new Map();
11
+ activeOperations = 0;
12
+ lastStatus = null;
13
+ constructor(canonicalDir, runtimes, logger, statusPath = null) {
14
+ this.canonicalDir = canonicalDir;
15
+ this.runtimes = runtimes;
16
+ this.logger = logger;
17
+ this.statusPath = statusPath;
18
+ }
19
+ async initialize() {
20
+ this.lastStatus = await readMirrorStatus(this.statusPath);
21
+ await fs.mkdir(this.canonicalDir, { recursive: true, mode: 0o700 });
22
+ await this.ensureCanonicalDefaultCandidate();
23
+ const candidateNames = await this.collectCandidateNames();
24
+ for (const name of candidateNames) {
25
+ await this.reconcileCandidateAtStartup(name);
26
+ }
27
+ const canonicalCandidateNames = await listAuthCandidateNames(this.canonicalDir);
28
+ const defaultCandidate = await this.resolveCanonicalCurrentCandidate()
29
+ ?? canonicalCandidateNames.sort()[0]
30
+ ?? null;
31
+ for (const runtime of this.runtimes) {
32
+ await fs.mkdir(runtime.authDir, { recursive: true, mode: 0o700 });
33
+ for (const name of canonicalCandidateNames) {
34
+ const destination = path.join(runtime.authDir, name);
35
+ if (!(await exists(destination))) {
36
+ await atomicCopy(path.join(this.canonicalDir, name), destination);
37
+ }
38
+ }
39
+ if (defaultCandidate && !(await exists(path.join(runtime.authDir, 'auth.json')))) {
40
+ await pointAuthSymlink(runtime.authDir, defaultCandidate);
41
+ }
42
+ }
43
+ }
44
+ start() {
45
+ if (this.timer)
46
+ return;
47
+ this.timer = setInterval(() => {
48
+ void this.scan().catch((error) => {
49
+ this.logger.warn('auth.mirror.scan_failed', { error: formatError(error) });
50
+ });
51
+ }, AUTH_SCAN_INTERVAL_MS);
52
+ this.timer.unref();
53
+ }
54
+ stop() {
55
+ if (!this.timer)
56
+ return;
57
+ clearInterval(this.timer);
58
+ this.timer = null;
59
+ }
60
+ isIdle() {
61
+ return this.activeOperations === 0;
62
+ }
63
+ getStatus() {
64
+ return this.lastStatus;
65
+ }
66
+ async syncRuntimeCandidate(runtimeId, candidateName) {
67
+ if (!isAuthCandidateName(candidateName))
68
+ return false;
69
+ const runtime = this.runtimes.find((entry) => entry.id === runtimeId);
70
+ if (!runtime)
71
+ return false;
72
+ return this.withActivity(() => this.propagateValidatedCandidate(runtime, candidateName));
73
+ }
74
+ async scan() {
75
+ await this.withActivity(async () => {
76
+ for (const runtime of this.runtimes) {
77
+ const names = await listAuthCandidateNames(runtime.authDir);
78
+ for (const name of names) {
79
+ await this.propagateValidatedCandidate(runtime, name);
80
+ }
81
+ }
82
+ });
83
+ }
84
+ async propagateValidatedCandidate(runtime, name) {
85
+ const sourcePath = path.join(runtime.authDir, name);
86
+ const record = await readChatGptAuthRecord(sourcePath);
87
+ if (!record)
88
+ return false;
89
+ const canonicalPath = path.join(this.canonicalDir, name);
90
+ const canonical = await readChatGptAuthRecord(canonicalPath);
91
+ if (canonical && canonical.accountId !== record.accountId) {
92
+ this.logger.warn('auth.mirror.account_conflict', { runtimeId: runtime.id, name });
93
+ return false;
94
+ }
95
+ const previousRefresh = Math.max(canonical?.lastRefreshMs ?? 0, this.lastSyncedRefresh.get(name) ?? 0);
96
+ if (record.lastRefreshMs <= previousRefresh) {
97
+ return false;
98
+ }
99
+ await atomicWrite(canonicalPath, record.raw);
100
+ for (const target of this.runtimes) {
101
+ if (target.id !== runtime.id) {
102
+ await atomicWrite(path.join(target.authDir, name), record.raw);
103
+ }
104
+ }
105
+ this.lastSyncedRefresh.set(name, record.lastRefreshMs);
106
+ const sourceLabel = runtime.label ?? runtime.id;
107
+ this.lastStatus = {
108
+ candidateName: name,
109
+ sourceRuntimeId: runtime.id,
110
+ sourceLabel,
111
+ syncedAt: new Date().toISOString(),
112
+ };
113
+ await writeMirrorStatus(this.statusPath, this.lastStatus);
114
+ this.logger.info('auth.mirror.synced', { sourceRuntimeId: runtime.id, name });
115
+ const message = `${name} has been refreshed by ${sourceLabel} and synchronized to the other Codex homes.`;
116
+ await Promise.allSettled(this.runtimes.map((target) => target.notify?.(message)));
117
+ return true;
118
+ }
119
+ async withActivity(operation) {
120
+ this.activeOperations += 1;
121
+ try {
122
+ return await operation();
123
+ }
124
+ finally {
125
+ this.activeOperations -= 1;
126
+ }
127
+ }
128
+ async ensureCanonicalDefaultCandidate() {
129
+ const names = await listAuthCandidateNames(this.canonicalDir);
130
+ if (names.length > 0)
131
+ return;
132
+ const authPath = path.join(this.canonicalDir, 'auth.json');
133
+ if (!(await exists(authPath)))
134
+ return;
135
+ const finalPath = await resolveFinalPath(authPath);
136
+ if (isAuthCandidateName(path.basename(finalPath)))
137
+ return;
138
+ await atomicCopy(authPath, path.join(this.canonicalDir, 'auth.json_default'));
139
+ }
140
+ async collectCandidateNames() {
141
+ const all = new Set(await listAuthCandidateNames(this.canonicalDir));
142
+ for (const runtime of this.runtimes) {
143
+ for (const name of await listAuthCandidateNames(runtime.authDir)) {
144
+ all.add(name);
145
+ }
146
+ }
147
+ return [...all];
148
+ }
149
+ async reconcileCandidateAtStartup(name) {
150
+ const paths = [
151
+ path.join(this.canonicalDir, name),
152
+ ...this.runtimes.map((runtime) => path.join(runtime.authDir, name)),
153
+ ];
154
+ const records = (await Promise.all(paths.map(async (sourcePath) => ({
155
+ sourcePath,
156
+ record: await readChatGptAuthRecord(sourcePath),
157
+ })))).filter((entry) => entry.record !== null);
158
+ if (records.length === 0)
159
+ return;
160
+ const accountIds = new Set(records.map((entry) => entry.record.accountId));
161
+ if (accountIds.size !== 1) {
162
+ this.logger.warn('auth.mirror.startup_conflict', { name });
163
+ return;
164
+ }
165
+ const newest = records.reduce((current, entry) => (entry.record.lastRefreshMs > current.record.lastRefreshMs ? entry : current));
166
+ this.lastSyncedRefresh.set(name, newest.record.lastRefreshMs);
167
+ for (const destination of paths) {
168
+ await atomicWrite(destination, newest.record.raw);
169
+ }
170
+ }
171
+ async resolveCanonicalCurrentCandidate() {
172
+ const finalPath = await resolveFinalPath(path.join(this.canonicalDir, 'auth.json'));
173
+ const name = path.basename(finalPath);
174
+ return isAuthCandidateName(name) ? name : null;
175
+ }
176
+ }
177
+ export function isAuthCandidateName(name) {
178
+ return name !== 'auth.json'
179
+ && !name.startsWith('.auth.json.')
180
+ && (name.startsWith('auth.json_') || name.startsWith('auth.json.') || name.startsWith('auth.json-'));
181
+ }
182
+ async function listAuthCandidateNames(dir) {
183
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
184
+ return entries
185
+ .filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && isAuthCandidateName(entry.name))
186
+ .map((entry) => entry.name);
187
+ }
188
+ async function readChatGptAuthRecord(filePath) {
189
+ try {
190
+ const raw = await fs.readFile(filePath, 'utf8');
191
+ const parsed = JSON.parse(raw);
192
+ const accountId = typeof parsed.tokens?.account_id === 'string' ? parsed.tokens.account_id : '';
193
+ const lastRefreshMs = typeof parsed.last_refresh === 'string' ? Date.parse(parsed.last_refresh) : NaN;
194
+ if (!accountId || !Number.isFinite(lastRefreshMs))
195
+ return null;
196
+ return { raw, accountId, lastRefreshMs };
197
+ }
198
+ catch {
199
+ return null;
200
+ }
201
+ }
202
+ async function atomicCopy(source, destination) {
203
+ await atomicWrite(destination, await fs.readFile(source, 'utf8'));
204
+ }
205
+ async function atomicWrite(destination, contents) {
206
+ await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
207
+ const temporary = path.join(path.dirname(destination), `.auth.json.${process.pid}.${Date.now()}.tmp`);
208
+ await fs.writeFile(temporary, contents, { encoding: 'utf8', mode: 0o600 });
209
+ await fs.rename(temporary, destination);
210
+ }
211
+ async function pointAuthSymlink(dir, candidateName) {
212
+ const temporary = path.join(dir, `.auth.json.${process.pid}.${Date.now()}.link`);
213
+ await fs.symlink(path.join(dir, candidateName), temporary);
214
+ await fs.rename(temporary, path.join(dir, 'auth.json'));
215
+ }
216
+ async function resolveFinalPath(sourcePath) {
217
+ try {
218
+ return await fs.realpath(sourcePath);
219
+ }
220
+ catch {
221
+ return sourcePath;
222
+ }
223
+ }
224
+ async function exists(filePath) {
225
+ return fs.lstat(filePath).then(() => true).catch(() => false);
226
+ }
227
+ function formatError(error) {
228
+ return error instanceof Error ? error.message : String(error);
229
+ }
230
+ async function readMirrorStatus(statusPath) {
231
+ if (!statusPath)
232
+ return null;
233
+ try {
234
+ const parsed = JSON.parse(await fs.readFile(statusPath, 'utf8'));
235
+ if (typeof parsed.candidateName !== 'string'
236
+ || typeof parsed.sourceRuntimeId !== 'string'
237
+ || typeof parsed.sourceLabel !== 'string'
238
+ || typeof parsed.syncedAt !== 'string') {
239
+ return null;
240
+ }
241
+ return {
242
+ candidateName: parsed.candidateName,
243
+ sourceRuntimeId: parsed.sourceRuntimeId,
244
+ sourceLabel: parsed.sourceLabel,
245
+ syncedAt: parsed.syncedAt,
246
+ };
247
+ }
248
+ catch {
249
+ return null;
250
+ }
251
+ }
252
+ async function writeMirrorStatus(statusPath, status) {
253
+ if (!statusPath)
254
+ return;
255
+ await fs.mkdir(path.dirname(statusPath), { recursive: true, mode: 0o700 });
256
+ const temporary = `${statusPath}.${process.pid}.${Date.now()}.tmp`;
257
+ await fs.writeFile(temporary, `${JSON.stringify(status, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
258
+ await fs.rename(temporary, statusPath);
259
+ }
@@ -58,6 +58,7 @@ interface CodexAppServerRuntimeStatus {
58
58
  interface StopOptions {
59
59
  terminateServer?: boolean;
60
60
  }
61
+ export declare function buildCodexAppServerArgs(port: number, configOverrides?: readonly string[]): string[];
61
62
  export declare class CodexAppClient extends EventEmitter {
62
63
  private readonly codexCliBin;
63
64
  private readonly launchCommand;
@@ -65,6 +66,8 @@ export declare class CodexAppClient extends EventEmitter {
65
66
  private readonly serverStatePath;
66
67
  private readonly serverLogPath;
67
68
  private readonly logger;
69
+ private readonly childEnv;
70
+ private readonly appServerConfigOverrides;
68
71
  private child;
69
72
  private socket;
70
73
  private requestId;
@@ -75,7 +78,7 @@ export declare class CodexAppClient extends EventEmitter {
75
78
  private port;
76
79
  private connected;
77
80
  private userAgent;
78
- constructor(codexCliBin: string, launchCommand: string, autolaunch: boolean, serverStatePath: string, serverLogPath: string, logger: Logger);
81
+ constructor(codexCliBin: string, launchCommand: string, autolaunch: boolean, serverStatePath: string, serverLogPath: string, logger: Logger, childEnv?: NodeJS.ProcessEnv | null, appServerConfigOverrides?: readonly string[]);
79
82
  isConnected(): boolean;
80
83
  getUserAgent(): string | null;
81
84
  getServerStatus(): CodexAppServerRuntimeStatus;
@@ -6,6 +6,14 @@ import { spawn } from 'node:child_process';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { buildThreadDeepLink, openUrl } from './deeplink.js';
8
8
  const CLIENT_VERSION = readPackageVersion();
9
+ export function buildCodexAppServerArgs(port, configOverrides = []) {
10
+ const args = ['app-server'];
11
+ for (const override of configOverrides) {
12
+ args.push('-c', override);
13
+ }
14
+ args.push('--listen', `ws://127.0.0.1:${port}`);
15
+ return args;
16
+ }
9
17
  export class CodexAppClient extends EventEmitter {
10
18
  codexCliBin;
11
19
  launchCommand;
@@ -13,6 +21,8 @@ export class CodexAppClient extends EventEmitter {
13
21
  serverStatePath;
14
22
  serverLogPath;
15
23
  logger;
24
+ childEnv;
25
+ appServerConfigOverrides;
16
26
  child = null;
17
27
  socket = null;
18
28
  requestId = 0;
@@ -23,7 +33,7 @@ export class CodexAppClient extends EventEmitter {
23
33
  port = null;
24
34
  connected = false;
25
35
  userAgent = null;
26
- constructor(codexCliBin, launchCommand, autolaunch, serverStatePath, serverLogPath, logger) {
36
+ constructor(codexCliBin, launchCommand, autolaunch, serverStatePath, serverLogPath, logger, childEnv = null, appServerConfigOverrides = []) {
27
37
  super();
28
38
  this.codexCliBin = codexCliBin;
29
39
  this.launchCommand = launchCommand;
@@ -31,6 +41,8 @@ export class CodexAppClient extends EventEmitter {
31
41
  this.serverStatePath = serverStatePath;
32
42
  this.serverLogPath = serverLogPath;
33
43
  this.logger = logger;
44
+ this.childEnv = childEnv;
45
+ this.appServerConfigOverrides = appServerConfigOverrides;
34
46
  }
35
47
  isConnected() {
36
48
  return this.connected;
@@ -463,16 +475,23 @@ export class CodexAppClient extends EventEmitter {
463
475
  return;
464
476
  }
465
477
  if (this.autolaunch) {
466
- const launcher = spawn(this.launchCommand, { shell: true, detached: true, stdio: 'ignore' });
478
+ const launcher = spawn(this.launchCommand, {
479
+ shell: true,
480
+ detached: true,
481
+ stdio: 'ignore',
482
+ env: this.childEnv ? { ...process.env, ...this.childEnv } : process.env,
483
+ });
467
484
  launcher.unref();
468
485
  }
469
486
  this.port = await reservePort();
487
+ const serverArgs = buildCodexAppServerArgs(this.port, this.appServerConfigOverrides);
470
488
  const [stdoutFd, stderrFd] = this.openServerLogFiles();
471
489
  let child;
472
490
  try {
473
- child = spawn(this.codexCliBin, ['app-server', '--listen', `ws://127.0.0.1:${this.port}`], {
491
+ child = spawn(this.codexCliBin, serverArgs, {
474
492
  detached: true,
475
493
  stdio: ['ignore', stdoutFd, stderrFd],
494
+ env: this.childEnv ? { ...process.env, ...this.childEnv } : process.env,
476
495
  });
477
496
  }
478
497
  finally {
@@ -488,7 +507,7 @@ export class CodexAppClient extends EventEmitter {
488
507
  this.writeServerState({
489
508
  pid: child.pid,
490
509
  port: this.port,
491
- command: `${this.codexCliBin} app-server --listen ws://127.0.0.1:${this.port}`,
510
+ command: [this.codexCliBin, ...serverArgs].join(' '),
492
511
  logPath: this.serverLogPath,
493
512
  bridgePid: process.pid,
494
513
  startedAt: new Date().toISOString(),
package/dist/config.d.ts CHANGED
@@ -7,12 +7,17 @@ export declare const DEFAULT_LOG_PATH: string;
7
7
  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
+ export declare const DEFAULT_CODEX_TELEGRAM_HOME: string;
10
11
  export declare const DEFAULT_ENV_PATH: string;
11
12
  export declare function resolveEnvPath(): string;
12
13
  export declare function getLoadedEnvPath(): string | null;
13
14
  export declare function loadEnv(): void;
14
15
  export interface AppConfig {
15
16
  tgBotToken: string;
17
+ tgBotTokens: string[];
18
+ tgMultiBotMode: boolean;
19
+ tgScopeBotId: string | null;
20
+ tgRequireExplicitGroupAddressing: boolean;
16
21
  tgAllowedUserId: string;
17
22
  tgAllowedChatId: string | null;
18
23
  tgAllowedTopicId: number | null;
@@ -21,6 +26,8 @@ export interface AppConfig {
21
26
  codexAppLaunchCmd: string;
22
27
  codexAppServerStatePath: string;
23
28
  codexAppServerLogPath: string;
29
+ codexAuthDir: string | null;
30
+ codexHome: string | null;
24
31
  codexAppSyncOnOpen: boolean;
25
32
  codexAppSyncOnTurnComplete: boolean;
26
33
  storePath: string;
package/dist/config.js CHANGED
@@ -10,6 +10,7 @@ export const DEFAULT_LOG_PATH = path.join(APP_HOME, 'logs', 'service.log');
10
10
  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
+ export const DEFAULT_CODEX_TELEGRAM_HOME = path.join(APP_HOME, 'codex', 'telegram');
13
14
  export const DEFAULT_ENV_PATH = path.join(APP_HOME, '.env');
14
15
  let envLoaded = false;
15
16
  let loadedEnvPath = null;
@@ -37,8 +38,22 @@ export function loadEnv() {
37
38
  }
38
39
  export function loadConfig() {
39
40
  loadEnv();
41
+ const configuredTokens = parseCommaSeparatedIds(process.env.TG_BOT_TOKENS);
42
+ const legacyToken = optional('TG_BOT_TOKEN');
43
+ const tgBotTokens = configuredTokens.length > 0
44
+ ? configuredTokens
45
+ : legacyToken
46
+ ? [legacyToken]
47
+ : [];
48
+ if (tgBotTokens.length === 0) {
49
+ throw new Error('TG_BOT_TOKENS or TG_BOT_TOKEN is required');
50
+ }
40
51
  const config = {
41
- tgBotToken: required('TG_BOT_TOKEN'),
52
+ tgBotToken: tgBotTokens[0],
53
+ tgBotTokens,
54
+ tgMultiBotMode: configuredTokens.length > 0,
55
+ tgScopeBotId: null,
56
+ tgRequireExplicitGroupAddressing: configuredTokens.length > 1,
42
57
  tgAllowedUserId: required('TG_ALLOWED_USER_ID'),
43
58
  tgAllowedChatId: optional('TG_ALLOWED_CHAT_ID'),
44
59
  tgAllowedTopicId: nullableIntEnv('TG_ALLOWED_TOPIC_ID'),
@@ -47,6 +62,8 @@ export function loadConfig() {
47
62
  codexAppLaunchCmd: process.env.CODEX_APP_LAUNCH_CMD || 'codex app',
48
63
  codexAppServerStatePath: process.env.CODEX_APP_SERVER_STATE_PATH || DEFAULT_CODEX_APP_SERVER_STATE_PATH,
49
64
  codexAppServerLogPath: process.env.CODEX_APP_SERVER_LOG_PATH || DEFAULT_CODEX_APP_SERVER_LOG_PATH,
65
+ codexAuthDir: process.env.CODEX_AUTH_DIR?.trim() || null,
66
+ codexHome: process.env.CODEX_HOME?.trim() || null,
50
67
  codexAppSyncOnOpen: boolEnv('CODEX_APP_SYNC_ON_OPEN', true),
51
68
  codexAppSyncOnTurnComplete: boolEnv('CODEX_APP_SYNC_ON_TURN_COMPLETE', false),
52
69
  storePath: process.env.STORE_PATH || DEFAULT_STORE_PATH,