@foxden-app/foxclaw 0.5.77 → 0.6.3
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 +11 -0
- package/CHANGELOG.md +52 -0
- package/README.md +17 -1
- package/README_EN.md +17 -1
- package/dist/auth/cross_node_sync.d.ts +7 -0
- package/dist/auth/cross_node_sync.js +191 -1
- package/dist/codex_app/client.d.ts +1 -0
- package/dist/codex_app/client.js +63 -12
- package/dist/config.d.ts +8 -0
- package/dist/config.js +18 -0
- package/dist/controller/controller.js +25 -23
- package/dist/controller/presentation.js +3 -1
- package/dist/i18n.d.ts +12 -6
- package/dist/i18n.js +45 -6
- package/dist/main.js +20 -1
- package/dist/opencode/client.d.ts +66 -0
- package/dist/opencode/client.js +499 -0
- package/dist/opencode/controller.d.ts +137 -0
- package/dist/opencode/controller.js +2009 -0
- package/dist/opencode/events.d.ts +61 -0
- package/dist/opencode/events.js +145 -0
- package/dist/opencode/runtime.d.ts +14 -0
- package/dist/opencode/runtime.js +29 -0
- package/dist/store/database.d.ts +1 -0
- package/dist/store/database.js +9 -0
- package/dist/telegram/gateway.d.ts +6 -1
- package/dist/telegram/gateway.js +6 -4
- package/dist/types.d.ts +1 -1
- package/docs/cross-node-auth-sync.md +2 -2
- package/docs/user-manual.md +5 -5
- package/docs/zh/cross-node-auth-sync.md +2 -2
- package/docs/zh/user-manual.md +5 -5
- package/package.json +3 -2
package/.env.example
CHANGED
|
@@ -60,6 +60,17 @@ TELEGRAM_PREVIEW_THROTTLE_MS=800
|
|
|
60
60
|
THREAD_LIST_LIMIT=10
|
|
61
61
|
CODEX_CLI_BIN=/absolute/path/to/codex
|
|
62
62
|
|
|
63
|
+
# Optional OpenCode bridge. This starts a second, independent Telegram bot
|
|
64
|
+
# alongside every existing Codex runtime; never reuse a TG_BOT_TOKEN here.
|
|
65
|
+
# FoxClaw manages a password-protected `opencode serve` bound to 127.0.0.1.
|
|
66
|
+
# OPENCODE_BOT_TOKEN=<different_telegram_bot_token>
|
|
67
|
+
# OPENCODE_CLI_BIN=/absolute/path/to/opencode
|
|
68
|
+
# Optional fixed Basic Auth password. If omitted, FoxClaw generates one and
|
|
69
|
+
# stores it with the managed server state (mode 0600).
|
|
70
|
+
# OPENCODE_SERVER_PASSWORD=
|
|
71
|
+
# OPENCODE_SERVER_STATE_PATH=
|
|
72
|
+
# OPENCODE_SERVER_LOG_PATH=
|
|
73
|
+
|
|
63
74
|
# Optional cross-node Codex auth sync.
|
|
64
75
|
# Disabled by default. When enabled, FoxClaw uses Telegram Bot-to-Bot private
|
|
65
76
|
# messages to move encrypted auth bundles between nodes. Enable Bot-to-Bot
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,58 @@
|
|
|
2
2
|
|
|
3
3
|
All notable FoxClaw changes are listed here. Each release note is bilingual so GitHub Releases and the npm package are useful to both Chinese and English readers.
|
|
4
4
|
|
|
5
|
+
## 0.6.3 - 2026-08-09
|
|
6
|
+
|
|
7
|
+
### 中文
|
|
8
|
+
- 修复 Codex app-server 重启只终止 Node 包装进程、未终止同一 detached 进程组内原生 Codex 子进程的问题。认证刷新或手动重载后,不再遗留旧服务持有 thread writer lock 并触发 `already has an active writer`。
|
|
9
|
+
- 托管服务关闭现在先向整个进程组发送 `SIGTERM`,超时后升级为 `SIGKILL`;强制终止后仍存活会明确失败并阻止新实例叠加启动。加入忽略 `SIGTERM` 的父子进程树回归测试。
|
|
10
|
+
|
|
11
|
+
### English
|
|
12
|
+
- Fixed Codex app-server restarts terminating only the Node wrapper while leaving the native Codex child alive in the same detached process group. Auth refreshes and manual reloads no longer leave an old server holding a thread writer lock and causing `already has an active writer`.
|
|
13
|
+
- Managed shutdown now sends `SIGTERM` to the whole process group, escalates to `SIGKILL` on timeout, and fails explicitly instead of starting another instance if the group still survives. Added a regression test with a parent/child process tree that ignores `SIGTERM`.
|
|
14
|
+
|
|
15
|
+
## 0.6.2 - 2026-08-09
|
|
16
|
+
|
|
17
|
+
### 中文
|
|
18
|
+
- 修复 OpenCode 流式刷新并发竞态:Telegram 首次发送尚未返回时,后续增量与结束事件会排队复用同一消息,不再把 `/home`、半截路径和最终路径拆成多条或重复发送;观察流和工具状态也使用同一串行保证。
|
|
19
|
+
- `/models` 改为 OpenCode 原生习惯的 Provider → Model 两层选择;`/setup` 对齐 Codex Bot 的同消息交互,集中显示并切换模型、variant、Agent/单次 Plan、权限和运行中新消息策略。
|
|
20
|
+
- 补齐可保持语义的菜单:原生 `/undo`/`redo`、`/review`、归档恢复,显式 `/steer`/`queue`/`takeover`,以及 Rich、Voice、MCP Apps、Plugins、Hooks、Features 和 Provider auth 状态。Codex 专属账户、配额、Goal、Remote、设备登录与升级协调入口保留在菜单中,并说明不可等价支持的原因和替代操作。
|
|
21
|
+
|
|
22
|
+
### English
|
|
23
|
+
- Fixed an OpenCode streaming race. When the first Telegram send is still pending, later deltas and completion now serialize onto the same message instead of emitting `/home`, partial paths, and duplicated final output. Watch streams and tool status use the same serialization guarantee.
|
|
24
|
+
- Changed `/models` to a native-style two-level Provider → Model selector. `/setup` now matches the Codex bot's in-place interaction and centralizes model, variant, Agent/one-shot Plan, access, and active-message behavior.
|
|
25
|
+
- Added every semantics-preserving menu path: native `/undo`/`redo`, `/review`, archive restore, explicit `/steer`/`queue`/`takeover`, Rich, Voice, MCP Apps, Plugins, Hooks, Features, and provider-auth status. Codex-only account, quota, Goal, Remote, device-login, and update-coordinator entries remain visible with precise limitations and replacement workflows.
|
|
26
|
+
|
|
27
|
+
## 0.6.1 - 2026-08-09
|
|
28
|
+
|
|
29
|
+
### 中文
|
|
30
|
+
- 新增独立 `OPENCODE_BOT_TOKEN` runtime,在不替换、不降级现有 Codex Bot 的前提下,由 Telegram 驱动本机 `opencode serve` 会话。支持全局会话列表、编号打开、观察、历史、Fork、Diff、压缩、文件搜索、Skills、MCP、Provider 与安全配置摘要。
|
|
31
|
+
- 对齐 OpenCode 1.18.15 当前 HTTP/SSE 契约:使用 `/global/health`、跨目录 `/global/event`、`permission.asked`、`question.asked` 以及当前 reply API;加入 SSE 重连、漏事件轮询兜底、崩溃后托管进程重连和跨目录待审批/提问恢复。
|
|
32
|
+
- Telegram 体验覆盖流式消息合并、最终 Rich Markdown、工具进度清理、图片/文件附件、模型与 variant、Agent/单次 Plan、read-only/default/full-access、运行中 steer/queue、审批按钮和多问题回答。`/undo`、`/rollback` 明确不做语义近似。
|
|
33
|
+
- `opencode serve` 只绑定 `127.0.0.1` 并强制 Basic Auth;未配置密码时自动生成,以 `0600` 写入状态文件。OpenCode 必须使用不同的 Telegram Bot token,`doctor` 会做 CLI 与 token 隔离检查。
|
|
34
|
+
- `v0.6.0` 保留为被回退的干扰提交快照,因此实际新实现使用 `0.6.1`,不复用同一版本号。
|
|
35
|
+
|
|
36
|
+
### English
|
|
37
|
+
- Added an independent `OPENCODE_BOT_TOKEN` runtime that drives local `opencode serve` sessions from Telegram without replacing or reducing existing Codex bots. It covers global session listing, numbered open, watch, history, fork, diff, compaction, file search, skills, MCP, providers, and a redacted config summary.
|
|
38
|
+
- Aligned with the current OpenCode 1.18.15 HTTP/SSE contract: `/global/health`, cross-directory `/global/event`, `permission.asked`, `question.asked`, and current reply endpoints. Added SSE reconnects, polling fallback, managed-process reattachment after crashes, and cross-directory pending-request recovery.
|
|
39
|
+
- Added merged streaming, final Rich Markdown, transient tool progress, image/file attachments, models and variants, Agent/one-shot Plan, three access presets, active-turn steer/queue, approval buttons, and multi-question answers. `/undo` and `/rollback` explicitly avoid semantic approximation.
|
|
40
|
+
- `opencode serve` binds only to `127.0.0.1` and always uses Basic Auth. FoxClaw generates a password when omitted and writes state with mode `0600`. OpenCode requires a separate Telegram bot token, and `doctor` checks both CLI availability and token isolation.
|
|
41
|
+
- `v0.6.0` remains the tagged snapshot of the reverted interference commit, so the corrected implementation is versioned `0.6.1` instead of reusing the same release number.
|
|
42
|
+
|
|
43
|
+
## 0.5.78 - 2026-07-12
|
|
44
|
+
|
|
45
|
+
### 中文
|
|
46
|
+
- `/auth` 面板收敛为“设备登录”和“安全同步”两个维护入口,移除权限、重载 auth 和单独的全节点审计按钮。“安全同步”现在直接执行完整的全节点验证、最新有效副本协商、8 天临期刷新和最终分发;`/auth sync audit` 继续作为兼容别名。
|
|
47
|
+
- 全节点审计现在也覆盖只存在于单个节点的旧候选:通过现有 AES-256-GCM 通道发送给其他节点做只验证、不导入的独立复核。任一节点验证有效就恢复并分发;没有有效结果且至少两个节点确认无效才标记 `?`。
|
|
48
|
+
- Telegram `/status` 新增当前 bot 实际使用的 Codex Home,并与 `/auth`、`/threads` 等时效面板一样按 `TELEGRAM_PANEL_TTL_MS` 自动撤回,默认 5 分钟。
|
|
49
|
+
- 完整支持 Codex 新增的 `max`、`ultra` 推理强度,包括模型能力读取、命令解析、设置持久化和下一轮请求透传,不再提示“未知推理强度”。
|
|
50
|
+
|
|
51
|
+
### English
|
|
52
|
+
- Simplified the `/auth` panel to Device login and Safe sync, removing the Permissions, Reload auth, and separate cluster-audit buttons. Safe sync now runs the complete all-node validation, newest-valid-copy reconciliation, 8-day stale refresh, and final distribution flow; `/auth sync audit` remains a compatibility alias.
|
|
53
|
+
- All-node audits now cover legacy candidates found on only one node. The candidate is sent through the existing AES-256-GCM channel for independent validation without import. Any valid result restores and distributes it; it is marked `?` only when no valid result exists and at least two nodes reject it.
|
|
54
|
+
- Telegram `/status` now shows the effective Codex Home for the current bot and expires through `TELEGRAM_PANEL_TTL_MS`, like `/auth` and `/threads`, with a five-minute default.
|
|
55
|
+
- Added end-to-end support for the new Codex `max` and `ultra` reasoning efforts across model capability parsing, commands, persisted settings, and turn requests.
|
|
56
|
+
|
|
5
57
|
## 0.5.77 - 2026-07-11
|
|
6
58
|
|
|
7
59
|
### 中文
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
**狸爪:一个更适配编程需求的移动端 Codex 控制器。**
|
|
6
6
|
|
|
7
|
-
FoxClaw(狸爪)的目标很直接:让你用手机控制本机的 Codex,把移动端变成一个真正能用的 web coding 入口。Telegram 或微信负责交互,`codex app-server` 负责本地执行;你发任务、看进度、批审批、切线程,
|
|
7
|
+
FoxClaw(狸爪)的目标很直接:让你用手机控制本机的 Codex 或 OpenCode,把移动端变成一个真正能用的 web coding 入口。Telegram 或微信负责交互,`codex app-server` / `opencode serve` 负责本地执行;你发任务、看进度、批审批、切线程,Agent 在电脑上继续写代码。
|
|
8
8
|
|
|
9
9
|
这适合你离开办公桌去吃饭、在旅途中、在跑步机上,或者陪小孩逛公园的时候继续工作。人可以离开电脑,Codex 不必停;它会把关键进展、错误、审批请求和最终结果同步回手机。
|
|
10
10
|
|
|
@@ -120,6 +120,22 @@ FoxClaw 只响应 `TG_ALLOWED_USER_ID` 的消息——把机器人拉进群不
|
|
|
120
120
|
|
|
121
121
|
</details>
|
|
122
122
|
|
|
123
|
+
## OpenCode 独立 Bot(0.6.1+)
|
|
124
|
+
|
|
125
|
+
现有 Codex Bot 不需要改。再向 `@BotFather` 创建一个独立机器人,把 token 写入:
|
|
126
|
+
|
|
127
|
+
```dotenv
|
|
128
|
+
OPENCODE_BOT_TOKEN=234567:different-opencode-bot-token
|
|
129
|
+
# 可选;默认从 PATH 查找
|
|
130
|
+
OPENCODE_CLI_BIN=/absolute/path/to/opencode
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
重启 FoxClaw 后,这个 Bot 会连接本机受 Basic Auth 保护、仅监听 `127.0.0.1` 的 `opencode serve`。未设置 `OPENCODE_SERVER_PASSWORD` 时,FoxClaw 自动生成密码并以 `0600` 保存到托管状态文件。`OPENCODE_BOT_TOKEN` 不能与 `TG_BOT_TOKENS` / `TG_BOT_TOKEN` 复用,否则两个 long-poll runtime 会争抢同一个 Telegram update。
|
|
134
|
+
|
|
135
|
+
OpenCode Bot 保留与 Codex Bot 相同的主要操作习惯:直接发消息或附件、`/new`、`/threads` + `/open <编号>`、`/watch`、同消息内刷新的 `/setup`、Provider → Model 两层模型选择、variant、Agent/单次 Plan、三档权限、运行中 `/steer`/`/queue`/`/takeover`、流式合并、工具进度、审批按钮和问题选项。`/undo`/`/redo`、`/review` 与归档使用 OpenCode 原生 API;`/apps` 对应 MCP,Plugins、Hooks、Features 和语音入口也保持可用。Codex 账户、配额、Goal、Remote 和 Fast service tier 没有 OpenCode serve 等价 API,保留菜单入口并明确说明替代操作。
|
|
136
|
+
|
|
137
|
+
OpenCode 自身的 Provider 登录仍在本机终端完成,例如 `opencode auth`。运行 `foxclaw doctor` 会在配置了 OpenCode Bot 时额外检查 OpenCode CLI 和 token 隔离。
|
|
138
|
+
|
|
123
139
|
## 多账号切换
|
|
124
140
|
|
|
125
141
|
FoxClaw 的一大特色是自动多账号切换。当一个账号触发用量限制时,FoxClaw 会自动切换到下一个可用账号继续工作。
|
package/README_EN.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
**A mobile Codex controller built for real programming workflows.**
|
|
6
6
|
|
|
7
|
-
FoxClaw turns your phone into a practical web coding cockpit for
|
|
7
|
+
FoxClaw turns your phone into a practical web coding cockpit for local Codex or OpenCode. Telegram or Weixin handles the chat interface, `codex app-server` / `opencode serve` handles local execution, and you can send tasks, inspect progress, approve actions, switch sessions, and keep working without opening a laptop.
|
|
8
8
|
|
|
9
9
|
It is built for the moments when you leave your desk for lunch, commute, travel, use a treadmill, or take the kids to the park. You can step away from the keyboard while Codex keeps coding and sends progress, errors, approval requests, and final results back to your phone.
|
|
10
10
|
|
|
@@ -120,6 +120,22 @@ FoxClaw accepts messages only from `TG_ALLOWED_USER_ID`. Putting the bot in a gr
|
|
|
120
120
|
|
|
121
121
|
</details>
|
|
122
122
|
|
|
123
|
+
## Independent OpenCode Bot (0.6.1+)
|
|
124
|
+
|
|
125
|
+
Your existing Codex bots remain unchanged. Create one more independent bot with `@BotFather` and configure:
|
|
126
|
+
|
|
127
|
+
```dotenv
|
|
128
|
+
OPENCODE_BOT_TOKEN=234567:different-opencode-bot-token
|
|
129
|
+
# Optional; defaults to PATH lookup
|
|
130
|
+
OPENCODE_CLI_BIN=/absolute/path/to/opencode
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
After FoxClaw restarts, this bot uses a managed `opencode serve` protected by Basic Auth and bound only to `127.0.0.1`. When `OPENCODE_SERVER_PASSWORD` is unset, FoxClaw generates one and saves it in the managed state file with mode `0600`. Never reuse a token from `TG_BOT_TOKENS` / `TG_BOT_TOKEN`, because two long-poll runtimes would compete for the same Telegram updates.
|
|
134
|
+
|
|
135
|
+
The OpenCode bot preserves the main Codex-bot habits: plain messages and attachments, `/new`, `/threads` plus numbered `/open`, `/watch`, an in-place `/setup` panel, a two-level Provider → Model selector, variants, Agent/one-shot Plan, three access presets, active-turn `/steer`/`queue`/`takeover`, merged streaming, tool progress, approval buttons, and question options. `/undo`/`redo`, `/review`, and archive operations use native OpenCode APIs; `/apps` maps to MCP, while Plugins, Hooks, Features, and voice entry points remain available. Codex account, quota, Goal, Remote, and Fast service-tier commands have no OpenCode serve equivalent, so their menu entries explain the limitation and replacement workflow.
|
|
136
|
+
|
|
137
|
+
Provider authentication remains an OpenCode terminal operation, such as `opencode auth`. When an OpenCode bot is configured, `foxclaw doctor` also checks the OpenCode CLI and token isolation.
|
|
138
|
+
|
|
123
139
|
## Multi-Account Rotation
|
|
124
140
|
|
|
125
141
|
A key FoxClaw feature is automatic multi-account switching. When one account's usage limit is triggered, FoxClaw automatically switches to the next available account and continues working.
|
|
@@ -299,6 +299,7 @@ export declare class CrossNodeAuthSync {
|
|
|
299
299
|
private readonly pendingLeases;
|
|
300
300
|
private readonly pendingTests;
|
|
301
301
|
private readonly pendingAudits;
|
|
302
|
+
private readonly pendingAuditVerifications;
|
|
302
303
|
private seenNonces;
|
|
303
304
|
private lastPeerActivityAt;
|
|
304
305
|
private timer;
|
|
@@ -338,11 +339,17 @@ export declare class CrossNodeAuthSync {
|
|
|
338
339
|
private handleMessage;
|
|
339
340
|
private handleAuditRequest;
|
|
340
341
|
private handleAuditResponse;
|
|
342
|
+
private handleAuditVerificationRequest;
|
|
343
|
+
private handleAuditVerificationResponse;
|
|
341
344
|
private handleAuditApply;
|
|
342
345
|
private applyAuditState;
|
|
343
346
|
private buildAuditReport;
|
|
347
|
+
private verifySingleNodeInvalidCandidates;
|
|
348
|
+
private verifyBundlesIntoAuditReport;
|
|
349
|
+
private validateAuditBundle;
|
|
344
350
|
private reconcileAuditReports;
|
|
345
351
|
private finishPendingAudit;
|
|
352
|
+
private finishPendingAuditVerification;
|
|
346
353
|
private handleServiceUpdateRequest;
|
|
347
354
|
private handlePullRequest;
|
|
348
355
|
private handlePullResponse;
|
|
@@ -27,6 +27,7 @@ export class CrossNodeAuthSync {
|
|
|
27
27
|
pendingLeases = new Map();
|
|
28
28
|
pendingTests = new Map();
|
|
29
29
|
pendingAudits = new Map();
|
|
30
|
+
pendingAuditVerifications = new Map();
|
|
30
31
|
seenNonces = new Map();
|
|
31
32
|
lastPeerActivityAt = new Map();
|
|
32
33
|
timer = null;
|
|
@@ -523,6 +524,7 @@ export class CrossNodeAuthSync {
|
|
|
523
524
|
}
|
|
524
525
|
const [localReport, remote] = await Promise.all([localReportPromise, remoteReportsPromise]);
|
|
525
526
|
const reports = [localReport, ...remote.reports.values()];
|
|
527
|
+
await this.verifySingleNodeInvalidCandidates(requestId, reports);
|
|
526
528
|
const result = await this.reconcileAuditReports(requestId, reports, remote.missing);
|
|
527
529
|
this.recordEvent({
|
|
528
530
|
direction: 'local',
|
|
@@ -641,6 +643,12 @@ export class CrossNodeAuthSync {
|
|
|
641
643
|
case 'audit.response':
|
|
642
644
|
this.handleAuditResponse(message, normalizePeerIdentity(peer));
|
|
643
645
|
return;
|
|
646
|
+
case 'audit.verify.request':
|
|
647
|
+
await this.handleAuditVerificationRequest(message, normalizePeerIdentity(peer));
|
|
648
|
+
return;
|
|
649
|
+
case 'audit.verify.response':
|
|
650
|
+
this.handleAuditVerificationResponse(message, normalizePeerIdentity(peer));
|
|
651
|
+
return;
|
|
644
652
|
case 'audit.apply':
|
|
645
653
|
await this.handleAuditApply(message, senderNodeId, sourceLabel, normalizePeerIdentity(peer));
|
|
646
654
|
return;
|
|
@@ -686,6 +694,62 @@ export class CrossNodeAuthSync {
|
|
|
686
694
|
this.finishPendingAudit(message.requestId);
|
|
687
695
|
}
|
|
688
696
|
}
|
|
697
|
+
async handleAuditVerificationRequest(message, peer) {
|
|
698
|
+
let report;
|
|
699
|
+
if (!this.callbacks.isIdle()) {
|
|
700
|
+
report = { nodeId: this.nodeId ?? 'local', status: 'busy', records: [], reason: 'runtime is not idle' };
|
|
701
|
+
}
|
|
702
|
+
else {
|
|
703
|
+
const records = [];
|
|
704
|
+
const bundles = Array.isArray(message.bundles)
|
|
705
|
+
? message.bundles.filter(bundle => isValidBundle(bundle) && sha256(bundle.rawAuth) === bundle.authSha256)
|
|
706
|
+
: [];
|
|
707
|
+
for (const bundle of bundles) {
|
|
708
|
+
const validation = await this.validateAuditBundle(bundle);
|
|
709
|
+
records.push({
|
|
710
|
+
candidateName: bundle.candidateName,
|
|
711
|
+
authSha256: bundle.authSha256,
|
|
712
|
+
status: validation.ok ? 'valid' : 'invalid',
|
|
713
|
+
reason: validation.ok ? null : validation.reason ?? 'usage validation failed',
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
report = { nodeId: this.nodeId ?? 'local', status: 'completed', records, reason: null };
|
|
717
|
+
}
|
|
718
|
+
await this.sendToPeer(peer, {
|
|
719
|
+
kind: 'audit.verify.response',
|
|
720
|
+
requestId: message.requestId,
|
|
721
|
+
verificationId: message.verificationId,
|
|
722
|
+
report,
|
|
723
|
+
});
|
|
724
|
+
this.recordEvent({
|
|
725
|
+
direction: 'local',
|
|
726
|
+
kind: 'audit.verify.response',
|
|
727
|
+
stage: 'sent',
|
|
728
|
+
peer,
|
|
729
|
+
requestId: message.requestId,
|
|
730
|
+
candidateName: null,
|
|
731
|
+
detail: `${report.status}; records=${report.records.length}`,
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
handleAuditVerificationResponse(message, peer) {
|
|
735
|
+
const pending = this.pendingAuditVerifications.get(message.verificationId);
|
|
736
|
+
if (!pending || pending.finished || !isValidAuditVerificationReport(message.report))
|
|
737
|
+
return;
|
|
738
|
+
const matchedPeer = this.matchConfiguredPeer(peer) ?? peer;
|
|
739
|
+
pending.reports.set(matchedPeer, message.report);
|
|
740
|
+
this.recordEvent({
|
|
741
|
+
direction: 'local',
|
|
742
|
+
kind: 'audit.verify.response',
|
|
743
|
+
stage: 'received',
|
|
744
|
+
peer: matchedPeer,
|
|
745
|
+
requestId: message.requestId,
|
|
746
|
+
candidateName: null,
|
|
747
|
+
detail: `${message.report.status}; records=${message.report.records.length}; node=${message.report.nodeId}`,
|
|
748
|
+
});
|
|
749
|
+
if (pending.peers.every(peerName => pending.reports.has(peerName))) {
|
|
750
|
+
this.finishPendingAuditVerification(message.verificationId);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
689
753
|
async handleAuditApply(message, senderNodeId, sourceLabel, peer) {
|
|
690
754
|
const outcome = await this.validateAndImport({ ...message.bundle, requestId: message.requestId }, senderNodeId, sourceLabel, peer, 'push', message.replaceExisting);
|
|
691
755
|
if (!outcome.ok)
|
|
@@ -747,7 +811,7 @@ export class CrossNodeAuthSync {
|
|
|
747
811
|
lastRefreshMs: candidate.lastRefreshMs,
|
|
748
812
|
status: validation.ok ? 'valid' : 'invalid',
|
|
749
813
|
reason: validation.ok ? null : validation.reason ?? 'usage validation failed',
|
|
750
|
-
bundle
|
|
814
|
+
bundle,
|
|
751
815
|
});
|
|
752
816
|
}
|
|
753
817
|
return {
|
|
@@ -757,6 +821,107 @@ export class CrossNodeAuthSync {
|
|
|
757
821
|
reason: null,
|
|
758
822
|
};
|
|
759
823
|
}
|
|
824
|
+
async verifySingleNodeInvalidCandidates(requestId, reports) {
|
|
825
|
+
const completedReports = reports.filter(report => report.status === 'completed');
|
|
826
|
+
const entries = new Map();
|
|
827
|
+
for (const report of completedReports) {
|
|
828
|
+
for (const record of report.records) {
|
|
829
|
+
const candidateEntries = entries.get(record.candidateName) ?? [];
|
|
830
|
+
candidateEntries.push({ report, record });
|
|
831
|
+
entries.set(record.candidateName, candidateEntries);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
const bundles = [];
|
|
835
|
+
for (const candidateEntries of entries.values()) {
|
|
836
|
+
if (candidateEntries.some(entry => entry.record.status === 'valid'))
|
|
837
|
+
continue;
|
|
838
|
+
const invalidNodes = new Set(candidateEntries.map(entry => entry.report.nodeId));
|
|
839
|
+
if (invalidNodes.size >= 2)
|
|
840
|
+
continue;
|
|
841
|
+
const latest = candidateEntries
|
|
842
|
+
.filter(entry => entry.record.bundle !== null)
|
|
843
|
+
.sort((left, right) => right.record.lastRefreshMs - left.record.lastRefreshMs)[0];
|
|
844
|
+
if (latest?.record.bundle)
|
|
845
|
+
bundles.push(latest.record.bundle);
|
|
846
|
+
}
|
|
847
|
+
if (bundles.length === 0)
|
|
848
|
+
return;
|
|
849
|
+
const localReport = completedReports.find(report => report.nodeId === this.nodeId);
|
|
850
|
+
if (localReport) {
|
|
851
|
+
await this.verifyBundlesIntoAuditReport(localReport, bundles);
|
|
852
|
+
}
|
|
853
|
+
if (!this.isReady())
|
|
854
|
+
return;
|
|
855
|
+
const verificationId = crypto.randomUUID();
|
|
856
|
+
const remoteReports = await new Promise((resolve) => {
|
|
857
|
+
const timer = setTimeout(() => this.finishPendingAuditVerification(verificationId), AUDIT_TIMEOUT_MS);
|
|
858
|
+
timer.unref();
|
|
859
|
+
this.pendingAuditVerifications.set(verificationId, {
|
|
860
|
+
peers: [...this.peers],
|
|
861
|
+
reports: new Map(),
|
|
862
|
+
resolve,
|
|
863
|
+
timer,
|
|
864
|
+
finished: false,
|
|
865
|
+
});
|
|
866
|
+
void this.sendToAll({ kind: 'audit.verify.request', requestId, verificationId, bundles }).catch(() => {
|
|
867
|
+
this.finishPendingAuditVerification(verificationId);
|
|
868
|
+
});
|
|
869
|
+
});
|
|
870
|
+
const bundlesByHash = new Map(bundles.map(bundle => [bundle.authSha256, bundle]));
|
|
871
|
+
for (const verification of remoteReports.values()) {
|
|
872
|
+
if (verification.status !== 'completed')
|
|
873
|
+
continue;
|
|
874
|
+
let target = completedReports.find(report => report.nodeId === verification.nodeId);
|
|
875
|
+
if (!target) {
|
|
876
|
+
target = { nodeId: verification.nodeId, status: 'completed', records: [], reason: null };
|
|
877
|
+
reports.push(target);
|
|
878
|
+
completedReports.push(target);
|
|
879
|
+
}
|
|
880
|
+
for (const record of verification.records) {
|
|
881
|
+
const bundle = bundlesByHash.get(record.authSha256);
|
|
882
|
+
if (!bundle || bundle.candidateName !== record.candidateName)
|
|
883
|
+
continue;
|
|
884
|
+
if (record.status === 'valid') {
|
|
885
|
+
target.records = target.records.filter(existing => existing.candidateName !== record.candidateName);
|
|
886
|
+
}
|
|
887
|
+
else if (target.records.some(existing => existing.candidateName === record.candidateName)) {
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
target.records.push({
|
|
891
|
+
candidateName: bundle.candidateName,
|
|
892
|
+
accountId: bundle.accountId,
|
|
893
|
+
quotaIdentityId: bundle.quotaIdentityId ?? null,
|
|
894
|
+
lastRefreshMs: bundle.lastRefreshMs,
|
|
895
|
+
status: record.status,
|
|
896
|
+
reason: record.reason,
|
|
897
|
+
bundle,
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
async verifyBundlesIntoAuditReport(report, bundles) {
|
|
903
|
+
for (const bundle of bundles) {
|
|
904
|
+
if (report.records.some(record => record.candidateName === bundle.candidateName))
|
|
905
|
+
continue;
|
|
906
|
+
const validation = await this.validateAuditBundle(bundle);
|
|
907
|
+
report.records.push({
|
|
908
|
+
candidateName: bundle.candidateName,
|
|
909
|
+
accountId: bundle.accountId,
|
|
910
|
+
quotaIdentityId: bundle.quotaIdentityId ?? null,
|
|
911
|
+
lastRefreshMs: bundle.lastRefreshMs,
|
|
912
|
+
status: validation.ok ? 'valid' : 'invalid',
|
|
913
|
+
reason: validation.ok ? null : validation.reason ?? 'usage validation failed',
|
|
914
|
+
bundle,
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
async validateAuditBundle(bundle) {
|
|
919
|
+
const expiresAt = readAccessTokenExpiresAtMs(bundle.rawAuth);
|
|
920
|
+
if (expiresAt === null || expiresAt <= Date.now() + REMOTE_ACCESS_TOKEN_MIN_TTL_MS) {
|
|
921
|
+
return { ok: false, reason: 'access token is expired or missing exp' };
|
|
922
|
+
}
|
|
923
|
+
return this.callbacks.validateCandidate(bundle.candidateName, bundle.rawAuth, bundle.accountId);
|
|
924
|
+
}
|
|
760
925
|
async reconcileAuditReports(requestId, reports, missingPeers) {
|
|
761
926
|
const completedReports = reports.filter(report => report.status === 'completed');
|
|
762
927
|
const busyNodes = reports.filter(report => report.status === 'busy').map(report => report.nodeId);
|
|
@@ -872,6 +1037,15 @@ export class CrossNodeAuthSync {
|
|
|
872
1037
|
const missing = pending.peers.filter(peer => !pending.reports.has(peer));
|
|
873
1038
|
pending.resolve({ reports: pending.reports, missing });
|
|
874
1039
|
}
|
|
1040
|
+
finishPendingAuditVerification(verificationId) {
|
|
1041
|
+
const pending = this.pendingAuditVerifications.get(verificationId);
|
|
1042
|
+
if (!pending || pending.finished)
|
|
1043
|
+
return;
|
|
1044
|
+
pending.finished = true;
|
|
1045
|
+
clearTimeout(pending.timer);
|
|
1046
|
+
this.pendingAuditVerifications.delete(verificationId);
|
|
1047
|
+
pending.resolve(pending.reports);
|
|
1048
|
+
}
|
|
875
1049
|
async handleServiceUpdateRequest(message, senderNodeId, sourceLabel, peer) {
|
|
876
1050
|
let result = {
|
|
877
1051
|
accepted: false,
|
|
@@ -1722,6 +1896,18 @@ function isValidAuditReport(value) {
|
|
|
1722
1896
|
&& (record.bundle === null || isValidBundle(record.bundle))
|
|
1723
1897
|
&& (record.status !== 'valid' || record.bundle !== null)));
|
|
1724
1898
|
}
|
|
1899
|
+
function isValidAuditVerificationReport(value) {
|
|
1900
|
+
return Boolean(value)
|
|
1901
|
+
&& typeof value.nodeId === 'string'
|
|
1902
|
+
&& (value.status === 'completed' || value.status === 'busy')
|
|
1903
|
+
&& Array.isArray(value.records)
|
|
1904
|
+
&& (typeof value.reason === 'string' || value.reason === null)
|
|
1905
|
+
&& value.records.every(record => (typeof record.candidateName === 'string'
|
|
1906
|
+
&& isAuthCandidateName(record.candidateName)
|
|
1907
|
+
&& typeof record.authSha256 === 'string'
|
|
1908
|
+
&& (record.status === 'valid' || record.status === 'invalid')
|
|
1909
|
+
&& (typeof record.reason === 'string' || record.reason === null)));
|
|
1910
|
+
}
|
|
1725
1911
|
function auditIdentityKey(accountId, quotaIdentityId) {
|
|
1726
1912
|
return `${accountId}\u0000${quotaIdentityId || accountId}`;
|
|
1727
1913
|
}
|
|
@@ -1835,6 +2021,8 @@ function requestIdFromMessage(message) {
|
|
|
1835
2021
|
case 'test.pong':
|
|
1836
2022
|
case 'audit.request':
|
|
1837
2023
|
case 'audit.response':
|
|
2024
|
+
case 'audit.verify.request':
|
|
2025
|
+
case 'audit.verify.response':
|
|
1838
2026
|
case 'audit.apply':
|
|
1839
2027
|
case 'audit.state':
|
|
1840
2028
|
case 'service.update.request':
|
|
@@ -1859,6 +2047,8 @@ function candidateNameFromMessage(message) {
|
|
|
1859
2047
|
return message.bundle?.candidateName ?? null;
|
|
1860
2048
|
case 'audit.apply':
|
|
1861
2049
|
return message.bundle.candidateName;
|
|
2050
|
+
case 'audit.verify.request':
|
|
2051
|
+
return message.bundles.length === 1 ? message.bundles[0]?.candidateName ?? null : null;
|
|
1862
2052
|
case 'audit.state':
|
|
1863
2053
|
return message.candidateName;
|
|
1864
2054
|
default:
|
|
@@ -176,4 +176,5 @@ export declare class CodexAppClient extends EventEmitter {
|
|
|
176
176
|
private clearServerState;
|
|
177
177
|
private clearServerStateForPid;
|
|
178
178
|
}
|
|
179
|
+
export declare function terminateManagedProcessGroup(pid: number, gracefulTimeoutMs?: number, forcedTimeoutMs?: number): Promise<'not-running' | 'terminated' | 'killed'>;
|
|
179
180
|
export {};
|
package/dist/codex_app/client.js
CHANGED
|
@@ -710,24 +710,24 @@ export class CodexAppClient extends EventEmitter {
|
|
|
710
710
|
const state = this.readServerState();
|
|
711
711
|
const pid = child?.pid ?? state?.pid ?? null;
|
|
712
712
|
this.child = null;
|
|
713
|
-
|
|
714
|
-
|
|
713
|
+
if (pid === null || !isManagedProcessGroupAlive(pid)) {
|
|
714
|
+
this.clearServerState();
|
|
715
715
|
return;
|
|
716
716
|
}
|
|
717
717
|
try {
|
|
718
|
-
|
|
718
|
+
const result = await terminateManagedProcessGroup(pid);
|
|
719
|
+
if (result === 'killed') {
|
|
720
|
+
this.logger.warn('codex.app-server.force_killed', { pid });
|
|
721
|
+
}
|
|
719
722
|
}
|
|
720
723
|
catch (error) {
|
|
721
724
|
this.logger.warn('codex.app-server.kill_failed', {
|
|
722
725
|
pid,
|
|
723
726
|
error: error instanceof Error ? error.message : String(error),
|
|
724
727
|
});
|
|
725
|
-
|
|
726
|
-
}
|
|
727
|
-
const exited = await waitForProcessExit(pid, 3000);
|
|
728
|
-
if (!exited) {
|
|
729
|
-
this.logger.warn('codex.app-server.kill_timeout', { pid });
|
|
728
|
+
throw error;
|
|
730
729
|
}
|
|
730
|
+
this.clearServerStateForPid(pid);
|
|
731
731
|
}
|
|
732
732
|
openServerLogFiles() {
|
|
733
733
|
fs.mkdirSync(path.dirname(this.serverLogPath), { recursive: true });
|
|
@@ -831,15 +831,66 @@ function isProcessAlive(pid) {
|
|
|
831
831
|
return error.code === 'EPERM';
|
|
832
832
|
}
|
|
833
833
|
}
|
|
834
|
-
async function
|
|
834
|
+
export async function terminateManagedProcessGroup(pid, gracefulTimeoutMs = 3000, forcedTimeoutMs = 2000) {
|
|
835
|
+
if (!isManagedProcessGroupAlive(pid)) {
|
|
836
|
+
return 'not-running';
|
|
837
|
+
}
|
|
838
|
+
signalManagedProcessGroup(pid, 'SIGTERM');
|
|
839
|
+
if (await waitForProcessGroupExit(pid, gracefulTimeoutMs)) {
|
|
840
|
+
return 'terminated';
|
|
841
|
+
}
|
|
842
|
+
signalManagedProcessGroup(pid, 'SIGKILL');
|
|
843
|
+
if (await waitForProcessGroupExit(pid, forcedTimeoutMs)) {
|
|
844
|
+
return 'killed';
|
|
845
|
+
}
|
|
846
|
+
throw new Error(`Codex app-server process group ${pid} did not exit after SIGKILL`);
|
|
847
|
+
}
|
|
848
|
+
function signalManagedProcessGroup(pid, signal) {
|
|
849
|
+
try {
|
|
850
|
+
process.kill(-pid, signal);
|
|
851
|
+
}
|
|
852
|
+
catch (error) {
|
|
853
|
+
if (error.code !== 'ESRCH') {
|
|
854
|
+
throw error;
|
|
855
|
+
}
|
|
856
|
+
try {
|
|
857
|
+
process.kill(pid, signal);
|
|
858
|
+
}
|
|
859
|
+
catch (fallbackError) {
|
|
860
|
+
if (fallbackError.code !== 'ESRCH') {
|
|
861
|
+
throw fallbackError;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
function isManagedProcessGroupAlive(pid) {
|
|
867
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
868
|
+
return false;
|
|
869
|
+
}
|
|
870
|
+
try {
|
|
871
|
+
process.kill(-pid, 0);
|
|
872
|
+
return true;
|
|
873
|
+
}
|
|
874
|
+
catch (error) {
|
|
875
|
+
const code = error.code;
|
|
876
|
+
if (code === 'EPERM') {
|
|
877
|
+
return true;
|
|
878
|
+
}
|
|
879
|
+
if (code === 'ESRCH') {
|
|
880
|
+
return isProcessAlive(pid);
|
|
881
|
+
}
|
|
882
|
+
throw error;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
async function waitForProcessGroupExit(pid, timeoutMs) {
|
|
835
886
|
const started = Date.now();
|
|
836
887
|
while (Date.now() - started < timeoutMs) {
|
|
837
|
-
if (!
|
|
888
|
+
if (!isManagedProcessGroupAlive(pid)) {
|
|
838
889
|
return true;
|
|
839
890
|
}
|
|
840
891
|
await sleep(100);
|
|
841
892
|
}
|
|
842
|
-
return !
|
|
893
|
+
return !isManagedProcessGroupAlive(pid);
|
|
843
894
|
}
|
|
844
895
|
function mapThread(raw) {
|
|
845
896
|
return {
|
|
@@ -1199,7 +1250,7 @@ function normalizeCollaborationMode(value) {
|
|
|
1199
1250
|
return value === 'default' || value === 'plan' ? value : null;
|
|
1200
1251
|
}
|
|
1201
1252
|
function normalizeReasoningEffort(value) {
|
|
1202
|
-
return typeof value === 'string' && ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'].includes(value)
|
|
1253
|
+
return typeof value === 'string' && ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'].includes(value)
|
|
1203
1254
|
? value
|
|
1204
1255
|
: null;
|
|
1205
1256
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ 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_OPENCODE_SERVER_STATE_PATH: string;
|
|
11
|
+
export declare const DEFAULT_OPENCODE_SERVER_LOG_PATH: string;
|
|
10
12
|
export declare const DEFAULT_CODEX_TELEGRAM_HOME: string;
|
|
11
13
|
export declare const DEFAULT_AUTH_SYNC_STATE_PATH: string;
|
|
12
14
|
export declare const DEFAULT_AUTH_SYNC_TEMP_DIR: string;
|
|
@@ -35,6 +37,11 @@ export interface AppConfig {
|
|
|
35
37
|
codexApiDefaultProvider: string | null;
|
|
36
38
|
codexAppSyncOnOpen: boolean;
|
|
37
39
|
codexAppSyncOnTurnComplete: boolean;
|
|
40
|
+
opencodeBotToken: string | null;
|
|
41
|
+
opencodeCliBin: string;
|
|
42
|
+
opencodeServerPassword: string | null;
|
|
43
|
+
opencodeServerStatePath: string;
|
|
44
|
+
opencodeServerLogPath: string;
|
|
38
45
|
storePath: string;
|
|
39
46
|
logLevel: LogLevel;
|
|
40
47
|
defaultCwd: string;
|
|
@@ -93,6 +100,7 @@ export interface CodexApiProviderConfig {
|
|
|
93
100
|
}
|
|
94
101
|
export declare function loadConfig(): AppConfig;
|
|
95
102
|
export declare function buildCodexApiProviderOverrides(providers: readonly CodexApiProviderConfig[], defaultProviderId?: string | null): string[];
|
|
103
|
+
export declare function validateOpencodeBotToken(token: string | null, codexTokens: readonly string[]): void;
|
|
96
104
|
export declare function parseCodexApiProviders(raw: string | undefined): CodexApiProviderConfig[];
|
|
97
105
|
export declare function selectDefaultRuntimeBotToken(configuredTokens: string[], legacyToken: string | null): string | null;
|
|
98
106
|
export declare function ensureAppDirs(config: AppConfig): void;
|
package/dist/config.js
CHANGED
|
@@ -10,6 +10,8 @@ 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_OPENCODE_SERVER_STATE_PATH = path.join(APP_HOME, 'runtime', 'opencode-server.json');
|
|
14
|
+
export const DEFAULT_OPENCODE_SERVER_LOG_PATH = path.join(APP_HOME, 'logs', 'opencode-server.log');
|
|
13
15
|
export const DEFAULT_CODEX_TELEGRAM_HOME = path.join(APP_HOME, 'codex', 'telegram');
|
|
14
16
|
export const DEFAULT_AUTH_SYNC_STATE_PATH = path.join(APP_HOME, 'runtime', 'auth-sync.json');
|
|
15
17
|
export const DEFAULT_AUTH_SYNC_TEMP_DIR = path.join(APP_HOME, 'runtime', 'auth-sync');
|
|
@@ -51,6 +53,8 @@ export function loadConfig() {
|
|
|
51
53
|
if (tgBotTokens.length === 0) {
|
|
52
54
|
throw new Error('TG_BOT_TOKENS or TG_BOT_TOKEN is required');
|
|
53
55
|
}
|
|
56
|
+
const opencodeBotToken = optional('OPENCODE_BOT_TOKEN');
|
|
57
|
+
validateOpencodeBotToken(opencodeBotToken, tgBotTokens);
|
|
54
58
|
const config = {
|
|
55
59
|
tgBotToken: tgBotTokens[0],
|
|
56
60
|
tgBotTokens,
|
|
@@ -72,6 +76,11 @@ export function loadConfig() {
|
|
|
72
76
|
codexApiDefaultProvider: optionalSanitizedProviderId(process.env.CODEX_API_DEFAULT_PROVIDER),
|
|
73
77
|
codexAppSyncOnOpen: boolEnv('CODEX_APP_SYNC_ON_OPEN', true),
|
|
74
78
|
codexAppSyncOnTurnComplete: boolEnv('CODEX_APP_SYNC_ON_TURN_COMPLETE', false),
|
|
79
|
+
opencodeBotToken,
|
|
80
|
+
opencodeCliBin: process.env.OPENCODE_CLI_BIN || resolveCommand('opencode') || 'opencode',
|
|
81
|
+
opencodeServerPassword: optional('OPENCODE_SERVER_PASSWORD'),
|
|
82
|
+
opencodeServerStatePath: process.env.OPENCODE_SERVER_STATE_PATH || DEFAULT_OPENCODE_SERVER_STATE_PATH,
|
|
83
|
+
opencodeServerLogPath: process.env.OPENCODE_SERVER_LOG_PATH || DEFAULT_OPENCODE_SERVER_LOG_PATH,
|
|
75
84
|
storePath: process.env.STORE_PATH || DEFAULT_STORE_PATH,
|
|
76
85
|
logLevel: parseLogLevel(process.env.LOG_LEVEL || 'info'),
|
|
77
86
|
defaultCwd: process.env.DEFAULT_CWD || process.cwd(),
|
|
@@ -140,6 +149,13 @@ export function buildCodexApiProviderOverrides(providers, defaultProviderId = nu
|
|
|
140
149
|
}
|
|
141
150
|
return overrides;
|
|
142
151
|
}
|
|
152
|
+
export function validateOpencodeBotToken(token, codexTokens) {
|
|
153
|
+
if (!token)
|
|
154
|
+
return;
|
|
155
|
+
if (codexTokens.includes(token)) {
|
|
156
|
+
throw new Error('OPENCODE_BOT_TOKEN must use a different bot from TG_BOT_TOKENS/TG_BOT_TOKEN');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
143
159
|
export function parseCodexApiProviders(raw) {
|
|
144
160
|
if (!raw?.trim())
|
|
145
161
|
return [];
|
|
@@ -249,6 +265,8 @@ export function ensureAppDirs(config) {
|
|
|
249
265
|
path.dirname(config.lockPath),
|
|
250
266
|
path.dirname(config.codexAppServerStatePath),
|
|
251
267
|
path.dirname(config.codexAppServerLogPath),
|
|
268
|
+
path.dirname(config.opencodeServerStatePath),
|
|
269
|
+
path.dirname(config.opencodeServerLogPath),
|
|
252
270
|
path.dirname(config.authSyncStatePath),
|
|
253
271
|
];
|
|
254
272
|
if (config.authSyncEnabled) {
|