@foxden-app/foxclaw 0.3.18 → 0.3.19
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/.env.example +5 -2
- package/README.md +14 -4
- package/README_EN.md +14 -4
- package/dist/auth/mirror.d.ts +25 -0
- package/dist/auth/mirror.js +199 -0
- package/dist/codex_app/client.d.ts +2 -1
- package/dist/codex_app/client.js +10 -2
- package/dist/config.d.ts +7 -0
- package/dist/config.js +18 -1
- package/dist/controller/controller.d.ts +13 -1
- package/dist/controller/controller.js +95 -25
- package/dist/core/bridge_scope.d.ts +5 -2
- package/dist/core/bridge_scope.js +7 -3
- package/dist/main.js +144 -9
- package/dist/store/database.d.ts +4 -2
- package/dist/store/database.js +42 -6
- package/dist/telegram/addressing.d.ts +1 -0
- package/dist/telegram/addressing.js +3 -0
- package/dist/telegram/gateway.d.ts +4 -1
- package/dist/telegram/gateway.js +23 -5
- package/dist/types.d.ts +7 -0
- package/dist/update.d.ts +5 -0
- package/dist/update.js +93 -5
- package/docs/agent-assisted-install.md +4 -4
- package/docs/install-for-beginners.md +3 -3
- package/docs/troubleshooting.md +1 -1
- package/docs/user-manual.md +10 -10
- package/docs/zh/agent-assisted-install.md +4 -4
- package/docs/zh/foxclaw-skill.md +1 -1
- package/docs/zh/install-for-beginners.md +3 -3
- package/docs/zh/troubleshooting.md +1 -1
- package/docs/zh/user-manual.md +10 -10
- package/package.json +1 -1
- package/skills/foxclaw/SKILL.md +24 -19
- package/skills/foxclaw/references/telegram-setup.md +5 -4
- package/skills/foxclaw/scripts/bootstrap_host.py +11 -8
- package/skills/foxclaw/scripts/bootstrap_remote.py +8 -4
package/.env.example
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
# Required: one bot
|
|
2
|
-
|
|
1
|
+
# Required: 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>
|
package/README.md
CHANGED
|
@@ -75,7 +75,7 @@ foxclaw start
|
|
|
75
75
|
跑 `doctor` 或 `start` 之前先把 `.env` 填好。私聊模式最小配置:
|
|
76
76
|
|
|
77
77
|
```dotenv
|
|
78
|
-
|
|
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
|
|
|
@@ -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`
|
|
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 的当前选择互不影响。
|
|
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
|
|
242
|
+
FoxClaw 会把 `codex app-server` 作为 detached 子进程启动,记录其 pid 和端口。使用 `TG_BOT_TOKENS` 时,每个 bot 都有自己的 app-server 与 Codex home;重启时如果对应进程还活着就直接重连,否则拉起新进程。`/auth_reload` 和认证切换只重启发起操作的 bot runtime。
|
|
233
243
|
|
|
234
244
|
一般不需要手动固定 app-server 端口。
|
|
235
245
|
|
package/README_EN.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
|
@@ -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
|
|
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.
|
|
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.
|
|
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. 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
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Logger } from '../logger.js';
|
|
2
|
+
export interface AuthMirrorRuntime {
|
|
3
|
+
id: string;
|
|
4
|
+
authDir: string;
|
|
5
|
+
notify?: (message: string) => Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export declare class AuthCandidateMirror {
|
|
8
|
+
private readonly canonicalDir;
|
|
9
|
+
private readonly runtimes;
|
|
10
|
+
private readonly logger;
|
|
11
|
+
private timer;
|
|
12
|
+
private readonly lastSyncedRefresh;
|
|
13
|
+
constructor(canonicalDir: string, runtimes: AuthMirrorRuntime[], logger: Logger);
|
|
14
|
+
initialize(): Promise<void>;
|
|
15
|
+
start(): void;
|
|
16
|
+
stop(): void;
|
|
17
|
+
syncRuntimeCandidate(runtimeId: string, candidateName: string): Promise<boolean>;
|
|
18
|
+
private scan;
|
|
19
|
+
private propagateValidatedCandidate;
|
|
20
|
+
private ensureCanonicalDefaultCandidate;
|
|
21
|
+
private collectCandidateNames;
|
|
22
|
+
private reconcileCandidateAtStartup;
|
|
23
|
+
private resolveCanonicalCurrentCandidate;
|
|
24
|
+
}
|
|
25
|
+
export declare function isAuthCandidateName(name: string): boolean;
|
|
@@ -0,0 +1,199 @@
|
|
|
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
|
+
timer = null;
|
|
9
|
+
lastSyncedRefresh = new Map();
|
|
10
|
+
constructor(canonicalDir, runtimes, logger) {
|
|
11
|
+
this.canonicalDir = canonicalDir;
|
|
12
|
+
this.runtimes = runtimes;
|
|
13
|
+
this.logger = logger;
|
|
14
|
+
}
|
|
15
|
+
async initialize() {
|
|
16
|
+
await fs.mkdir(this.canonicalDir, { recursive: true, mode: 0o700 });
|
|
17
|
+
await this.ensureCanonicalDefaultCandidate();
|
|
18
|
+
const candidateNames = await this.collectCandidateNames();
|
|
19
|
+
for (const name of candidateNames) {
|
|
20
|
+
await this.reconcileCandidateAtStartup(name);
|
|
21
|
+
}
|
|
22
|
+
const canonicalCandidateNames = await listAuthCandidateNames(this.canonicalDir);
|
|
23
|
+
const defaultCandidate = await this.resolveCanonicalCurrentCandidate()
|
|
24
|
+
?? canonicalCandidateNames.sort()[0]
|
|
25
|
+
?? null;
|
|
26
|
+
for (const runtime of this.runtimes) {
|
|
27
|
+
await fs.mkdir(runtime.authDir, { recursive: true, mode: 0o700 });
|
|
28
|
+
for (const name of canonicalCandidateNames) {
|
|
29
|
+
const destination = path.join(runtime.authDir, name);
|
|
30
|
+
if (!(await exists(destination))) {
|
|
31
|
+
await atomicCopy(path.join(this.canonicalDir, name), destination);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (defaultCandidate && !(await exists(path.join(runtime.authDir, 'auth.json')))) {
|
|
35
|
+
await pointAuthSymlink(runtime.authDir, defaultCandidate);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
start() {
|
|
40
|
+
if (this.timer)
|
|
41
|
+
return;
|
|
42
|
+
this.timer = setInterval(() => {
|
|
43
|
+
void this.scan().catch((error) => {
|
|
44
|
+
this.logger.warn('auth.mirror.scan_failed', { error: formatError(error) });
|
|
45
|
+
});
|
|
46
|
+
}, AUTH_SCAN_INTERVAL_MS);
|
|
47
|
+
this.timer.unref();
|
|
48
|
+
}
|
|
49
|
+
stop() {
|
|
50
|
+
if (!this.timer)
|
|
51
|
+
return;
|
|
52
|
+
clearInterval(this.timer);
|
|
53
|
+
this.timer = null;
|
|
54
|
+
}
|
|
55
|
+
async syncRuntimeCandidate(runtimeId, candidateName) {
|
|
56
|
+
if (!isAuthCandidateName(candidateName))
|
|
57
|
+
return false;
|
|
58
|
+
const runtime = this.runtimes.find((entry) => entry.id === runtimeId);
|
|
59
|
+
if (!runtime)
|
|
60
|
+
return false;
|
|
61
|
+
return this.propagateValidatedCandidate(runtime, candidateName);
|
|
62
|
+
}
|
|
63
|
+
async scan() {
|
|
64
|
+
for (const runtime of this.runtimes) {
|
|
65
|
+
const names = await listAuthCandidateNames(runtime.authDir);
|
|
66
|
+
for (const name of names) {
|
|
67
|
+
await this.propagateValidatedCandidate(runtime, name);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async propagateValidatedCandidate(runtime, name) {
|
|
72
|
+
const sourcePath = path.join(runtime.authDir, name);
|
|
73
|
+
const record = await readChatGptAuthRecord(sourcePath);
|
|
74
|
+
if (!record)
|
|
75
|
+
return false;
|
|
76
|
+
const canonicalPath = path.join(this.canonicalDir, name);
|
|
77
|
+
const canonical = await readChatGptAuthRecord(canonicalPath);
|
|
78
|
+
if (canonical && canonical.accountId !== record.accountId) {
|
|
79
|
+
this.logger.warn('auth.mirror.account_conflict', { runtimeId: runtime.id, name });
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
const previousRefresh = Math.max(canonical?.lastRefreshMs ?? 0, this.lastSyncedRefresh.get(name) ?? 0);
|
|
83
|
+
if (record.lastRefreshMs <= previousRefresh) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
await atomicWrite(canonicalPath, record.raw);
|
|
87
|
+
for (const target of this.runtimes) {
|
|
88
|
+
if (target.id !== runtime.id) {
|
|
89
|
+
await atomicWrite(path.join(target.authDir, name), record.raw);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
this.lastSyncedRefresh.set(name, record.lastRefreshMs);
|
|
93
|
+
this.logger.info('auth.mirror.synced', { sourceRuntimeId: runtime.id, name });
|
|
94
|
+
const message = `${name} has been refreshed by ${runtime.id} and synchronized to the other Codex homes.`;
|
|
95
|
+
await Promise.allSettled(this.runtimes.map((target) => target.notify?.(message)));
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
async ensureCanonicalDefaultCandidate() {
|
|
99
|
+
const names = await listAuthCandidateNames(this.canonicalDir);
|
|
100
|
+
if (names.length > 0)
|
|
101
|
+
return;
|
|
102
|
+
const authPath = path.join(this.canonicalDir, 'auth.json');
|
|
103
|
+
if (!(await exists(authPath)))
|
|
104
|
+
return;
|
|
105
|
+
const finalPath = await resolveFinalPath(authPath);
|
|
106
|
+
if (isAuthCandidateName(path.basename(finalPath)))
|
|
107
|
+
return;
|
|
108
|
+
await atomicCopy(authPath, path.join(this.canonicalDir, 'auth.json_default'));
|
|
109
|
+
}
|
|
110
|
+
async collectCandidateNames() {
|
|
111
|
+
const all = new Set(await listAuthCandidateNames(this.canonicalDir));
|
|
112
|
+
for (const runtime of this.runtimes) {
|
|
113
|
+
for (const name of await listAuthCandidateNames(runtime.authDir)) {
|
|
114
|
+
all.add(name);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return [...all];
|
|
118
|
+
}
|
|
119
|
+
async reconcileCandidateAtStartup(name) {
|
|
120
|
+
const paths = [
|
|
121
|
+
path.join(this.canonicalDir, name),
|
|
122
|
+
...this.runtimes.map((runtime) => path.join(runtime.authDir, name)),
|
|
123
|
+
];
|
|
124
|
+
const records = (await Promise.all(paths.map(async (sourcePath) => ({
|
|
125
|
+
sourcePath,
|
|
126
|
+
record: await readChatGptAuthRecord(sourcePath),
|
|
127
|
+
})))).filter((entry) => entry.record !== null);
|
|
128
|
+
if (records.length === 0)
|
|
129
|
+
return;
|
|
130
|
+
const accountIds = new Set(records.map((entry) => entry.record.accountId));
|
|
131
|
+
if (accountIds.size !== 1) {
|
|
132
|
+
this.logger.warn('auth.mirror.startup_conflict', { name });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const newest = records.reduce((current, entry) => (entry.record.lastRefreshMs > current.record.lastRefreshMs ? entry : current));
|
|
136
|
+
this.lastSyncedRefresh.set(name, newest.record.lastRefreshMs);
|
|
137
|
+
for (const destination of paths) {
|
|
138
|
+
await atomicWrite(destination, newest.record.raw);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async resolveCanonicalCurrentCandidate() {
|
|
142
|
+
const finalPath = await resolveFinalPath(path.join(this.canonicalDir, 'auth.json'));
|
|
143
|
+
const name = path.basename(finalPath);
|
|
144
|
+
return isAuthCandidateName(name) ? name : null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
export function isAuthCandidateName(name) {
|
|
148
|
+
return name !== 'auth.json'
|
|
149
|
+
&& !name.startsWith('.auth.json.')
|
|
150
|
+
&& (name.startsWith('auth.json_') || name.startsWith('auth.json.') || name.startsWith('auth.json-'));
|
|
151
|
+
}
|
|
152
|
+
async function listAuthCandidateNames(dir) {
|
|
153
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
154
|
+
return entries
|
|
155
|
+
.filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && isAuthCandidateName(entry.name))
|
|
156
|
+
.map((entry) => entry.name);
|
|
157
|
+
}
|
|
158
|
+
async function readChatGptAuthRecord(filePath) {
|
|
159
|
+
try {
|
|
160
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
161
|
+
const parsed = JSON.parse(raw);
|
|
162
|
+
const accountId = typeof parsed.tokens?.account_id === 'string' ? parsed.tokens.account_id : '';
|
|
163
|
+
const lastRefreshMs = typeof parsed.last_refresh === 'string' ? Date.parse(parsed.last_refresh) : NaN;
|
|
164
|
+
if (!accountId || !Number.isFinite(lastRefreshMs))
|
|
165
|
+
return null;
|
|
166
|
+
return { raw, accountId, lastRefreshMs };
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async function atomicCopy(source, destination) {
|
|
173
|
+
await atomicWrite(destination, await fs.readFile(source, 'utf8'));
|
|
174
|
+
}
|
|
175
|
+
async function atomicWrite(destination, contents) {
|
|
176
|
+
await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
|
|
177
|
+
const temporary = path.join(path.dirname(destination), `.auth.json.${process.pid}.${Date.now()}.tmp`);
|
|
178
|
+
await fs.writeFile(temporary, contents, { encoding: 'utf8', mode: 0o600 });
|
|
179
|
+
await fs.rename(temporary, destination);
|
|
180
|
+
}
|
|
181
|
+
async function pointAuthSymlink(dir, candidateName) {
|
|
182
|
+
const temporary = path.join(dir, `.auth.json.${process.pid}.${Date.now()}.link`);
|
|
183
|
+
await fs.symlink(path.join(dir, candidateName), temporary);
|
|
184
|
+
await fs.rename(temporary, path.join(dir, 'auth.json'));
|
|
185
|
+
}
|
|
186
|
+
async function resolveFinalPath(sourcePath) {
|
|
187
|
+
try {
|
|
188
|
+
return await fs.realpath(sourcePath);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return sourcePath;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async function exists(filePath) {
|
|
195
|
+
return fs.lstat(filePath).then(() => true).catch(() => false);
|
|
196
|
+
}
|
|
197
|
+
function formatError(error) {
|
|
198
|
+
return error instanceof Error ? error.message : String(error);
|
|
199
|
+
}
|
|
@@ -65,6 +65,7 @@ export declare class CodexAppClient extends EventEmitter {
|
|
|
65
65
|
private readonly serverStatePath;
|
|
66
66
|
private readonly serverLogPath;
|
|
67
67
|
private readonly logger;
|
|
68
|
+
private readonly childEnv;
|
|
68
69
|
private child;
|
|
69
70
|
private socket;
|
|
70
71
|
private requestId;
|
|
@@ -75,7 +76,7 @@ export declare class CodexAppClient extends EventEmitter {
|
|
|
75
76
|
private port;
|
|
76
77
|
private connected;
|
|
77
78
|
private userAgent;
|
|
78
|
-
constructor(codexCliBin: string, launchCommand: string, autolaunch: boolean, serverStatePath: string, serverLogPath: string, logger: Logger);
|
|
79
|
+
constructor(codexCliBin: string, launchCommand: string, autolaunch: boolean, serverStatePath: string, serverLogPath: string, logger: Logger, childEnv?: NodeJS.ProcessEnv | null);
|
|
79
80
|
isConnected(): boolean;
|
|
80
81
|
getUserAgent(): string | null;
|
|
81
82
|
getServerStatus(): CodexAppServerRuntimeStatus;
|
package/dist/codex_app/client.js
CHANGED
|
@@ -13,6 +13,7 @@ export class CodexAppClient extends EventEmitter {
|
|
|
13
13
|
serverStatePath;
|
|
14
14
|
serverLogPath;
|
|
15
15
|
logger;
|
|
16
|
+
childEnv;
|
|
16
17
|
child = null;
|
|
17
18
|
socket = null;
|
|
18
19
|
requestId = 0;
|
|
@@ -23,7 +24,7 @@ export class CodexAppClient extends EventEmitter {
|
|
|
23
24
|
port = null;
|
|
24
25
|
connected = false;
|
|
25
26
|
userAgent = null;
|
|
26
|
-
constructor(codexCliBin, launchCommand, autolaunch, serverStatePath, serverLogPath, logger) {
|
|
27
|
+
constructor(codexCliBin, launchCommand, autolaunch, serverStatePath, serverLogPath, logger, childEnv = null) {
|
|
27
28
|
super();
|
|
28
29
|
this.codexCliBin = codexCliBin;
|
|
29
30
|
this.launchCommand = launchCommand;
|
|
@@ -31,6 +32,7 @@ export class CodexAppClient extends EventEmitter {
|
|
|
31
32
|
this.serverStatePath = serverStatePath;
|
|
32
33
|
this.serverLogPath = serverLogPath;
|
|
33
34
|
this.logger = logger;
|
|
35
|
+
this.childEnv = childEnv;
|
|
34
36
|
}
|
|
35
37
|
isConnected() {
|
|
36
38
|
return this.connected;
|
|
@@ -463,7 +465,12 @@ export class CodexAppClient extends EventEmitter {
|
|
|
463
465
|
return;
|
|
464
466
|
}
|
|
465
467
|
if (this.autolaunch) {
|
|
466
|
-
const launcher = spawn(this.launchCommand, {
|
|
468
|
+
const launcher = spawn(this.launchCommand, {
|
|
469
|
+
shell: true,
|
|
470
|
+
detached: true,
|
|
471
|
+
stdio: 'ignore',
|
|
472
|
+
env: this.childEnv ? { ...process.env, ...this.childEnv } : process.env,
|
|
473
|
+
});
|
|
467
474
|
launcher.unref();
|
|
468
475
|
}
|
|
469
476
|
this.port = await reservePort();
|
|
@@ -473,6 +480,7 @@ export class CodexAppClient extends EventEmitter {
|
|
|
473
480
|
child = spawn(this.codexCliBin, ['app-server', '--listen', `ws://127.0.0.1:${this.port}`], {
|
|
474
481
|
detached: true,
|
|
475
482
|
stdio: ['ignore', stdoutFd, stderrFd],
|
|
483
|
+
env: this.childEnv ? { ...process.env, ...this.childEnv } : process.env,
|
|
476
484
|
});
|
|
477
485
|
}
|
|
478
486
|
finally {
|
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:
|
|
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,
|
|
@@ -6,6 +6,11 @@ import type { TelegramGateway, TelegramTextEvent } from '../telegram/gateway.js'
|
|
|
6
6
|
import { BridgeMessagingRouter } from '../channels/bridge_messaging_router.js';
|
|
7
7
|
import type { CodexAppClient } from '../codex_app/client.js';
|
|
8
8
|
import type { SelfUpdateRuntime } from '../update.js';
|
|
9
|
+
export interface CoreCoordinator {
|
|
10
|
+
canSelfUpdate?: () => boolean;
|
|
11
|
+
authCandidateUpdated?: (runtimeId: string, candidateName: string) => Promise<void>;
|
|
12
|
+
statusUpdated?: (status: RuntimeStatus) => void;
|
|
13
|
+
}
|
|
9
14
|
export declare class BridgeSessionCore {
|
|
10
15
|
private readonly config;
|
|
11
16
|
private readonly store;
|
|
@@ -13,6 +18,7 @@ export declare class BridgeSessionCore {
|
|
|
13
18
|
private readonly bot;
|
|
14
19
|
private readonly app;
|
|
15
20
|
private readonly selfUpdater;
|
|
21
|
+
private readonly coordinator;
|
|
16
22
|
private activeTurns;
|
|
17
23
|
private activeTurnsByTurnId;
|
|
18
24
|
private observedThreadWatchers;
|
|
@@ -51,7 +57,7 @@ export declare class BridgeSessionCore {
|
|
|
51
57
|
/** Last threads-panel pagination state per scope (Telegram inline nav + /open index alignment). */
|
|
52
58
|
private threadListPresentationState;
|
|
53
59
|
private readonly messaging;
|
|
54
|
-
constructor(config: AppConfig, store: BridgeStore, logger: Logger, bot: TelegramGateway, app: CodexAppClient, outbound: BridgeMessagingRouter, selfUpdater?: SelfUpdateRuntime | null);
|
|
60
|
+
constructor(config: AppConfig, store: BridgeStore, logger: Logger, bot: TelegramGateway, app: CodexAppClient, outbound: BridgeMessagingRouter, selfUpdater?: SelfUpdateRuntime | null, coordinator?: CoreCoordinator | null);
|
|
55
61
|
/** Wire Telegram inbound events. Call before {@link startCodexApp}. */
|
|
56
62
|
registerTelegramInboundHandlers(): void;
|
|
57
63
|
/**
|
|
@@ -140,6 +146,10 @@ export declare class BridgeSessionCore {
|
|
|
140
146
|
private stageAttachments;
|
|
141
147
|
private registerActiveTurn;
|
|
142
148
|
private createActiveTurnState;
|
|
149
|
+
isIdleForServiceUpdate(): boolean;
|
|
150
|
+
private hasLocalBlockingActivity;
|
|
151
|
+
private authRuntimeId;
|
|
152
|
+
private ownsScope;
|
|
143
153
|
private setActiveTurn;
|
|
144
154
|
private getActiveTurn;
|
|
145
155
|
private getActiveTurnsForTurn;
|
|
@@ -261,6 +271,7 @@ export declare class BridgeSessionCore {
|
|
|
261
271
|
private retryTurnAfterAuthRotation;
|
|
262
272
|
private selectNextCodexAuthCandidate;
|
|
263
273
|
private listCodexAuthState;
|
|
274
|
+
private resolveAuthDir;
|
|
264
275
|
private readCodexAuthSwitchLabels;
|
|
265
276
|
private codexAuthSwitchParams;
|
|
266
277
|
private switchCodexAuthAndRestart;
|
|
@@ -277,6 +288,7 @@ export declare class BridgeSessionCore {
|
|
|
277
288
|
private readCodexAuthQuotaSnapshots;
|
|
278
289
|
private writeCodexAuthQuotaSnapshots;
|
|
279
290
|
private codexAuthQuotaSnapshotPath;
|
|
291
|
+
private runtimeSnapshotFilename;
|
|
280
292
|
private sendThreadContextSummary;
|
|
281
293
|
private handleModelCommand;
|
|
282
294
|
private handleEffortCommand;
|