xuym-codex-collab 0.1.1 → 0.3.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.
- package/README.md +61 -110
- package/apps/onboarding/bundle.mjs +24 -0
- package/apps/onboarding/discovery.mjs +44 -0
- package/apps/onboarding/errors.mjs +1 -0
- package/apps/onboarding/main.mjs +32 -0
- package/apps/onboarding/manager.mjs +405 -0
- package/apps/onboarding/server.mjs +56 -0
- package/apps/onboarding/web/app.js +128 -0
- package/apps/onboarding/web/index.html +13 -0
- package/apps/onboarding/web/style.css +3 -0
- package/apps/peer/desktop.mjs +8 -6
- package/apps/peer/mcp.mjs +1 -1
- package/apps/peer/runner.mjs +5 -5
- package/apps/peer/runtime.mjs +4 -1
- package/apps/peer/wecom.mjs +70 -21
- package/bin/codex-collab.mjs +10 -7
- package/docs/architecture.md +3 -1
- package/docs/desktop-poll.md +15 -7
- package/docs/legacy-center.md +109 -0
- package/docs/onboarding.md +74 -72
- package/docs/peer.md +12 -6
- package/docs/protocol.md +2 -0
- package/docs/team-registry.md +55 -0
- package/docs/terminal-onboarding.md +98 -0
- package/package.json +6 -3
- package/packages/codex-kit/templates/.agents/skills/team-collab-desktop/SKILL.md +3 -3
- package/packages/peer/config.mjs +4 -1
- package/packages/peer/protocol.mjs +22 -7
- package/packages/peer/registry.mjs +130 -0
- package/packages/peer/review.mjs +51 -0
- package/packages/peer/service.mjs +24 -16
- package/packages/peer/store.mjs +50 -5
- package/schemas/encrypted-invite.schema.json +42 -0
- package/schemas/event.schema.json +51 -0
- package/schemas/peer-event.schema.json +44 -0
- package/schemas/peer-task.schema.json +21 -0
- package/schemas/task.schema.json +18 -0
- package/schemas/team-enrollment.schema.json +61 -0
- package/schemas/team-invitation.schema.json +86 -0
- package/schemas/team-policy.schema.json +182 -0
- package/schemas/team-version.schema.json +70 -0
- package/scripts/peer/cli.mjs +5 -2
- package/scripts/peer/onboarding.mjs +1 -1
package/README.md
CHANGED
|
@@ -1,141 +1,92 @@
|
|
|
1
1
|
# Codex Collab
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
把企业微信群的任务交给指定的已有 Codex 对话:管理员统一创建并分配机器人,同事导入个人接入包、绑定对话,即可接单、提交结果、通知真人审核并接力。每位成员只运行本机工具;共享任务协议和交接记录保存在业务仓库的 `agent-collab` 分支。
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 安装与入口
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
**指定已有桌面对话轮询模式 `desktop_poll` 已完成本机真实闭环验证**。据用户于 2026-10-10 确认,真实企业微信群任务已由 Alice 的既有 Codex 对话接单执行,再由 Bob 的既有 Codex 对话接力;本机适配器负责收群消息/发通知,MCP 提供对话绑定、原子接单、隔离工作树、续租、固定测试和交接,已有对话的 app heartbeat 负责轮询。跨两台机器部署仍未验证。当前全套自动测试 66 项通过。操作见 [`docs/desktop-poll.md`](docs/desktop-poll.md)。
|
|
10
|
-
|
|
11
|
-
## 推荐:Peer 模式(不部署中心服务)
|
|
12
|
-
|
|
13
|
-
如果不想运行共享 HTTP 服务,使用已实现的 Peer 模式:每位同事本机安装 Skill + stdio MCP,并运行自己的 Peer Runner;任务、签名事件、候补分配和交接 commit 直接写入目标仓库的 `agent-collab` 分支。企业微信机器人是可选的命令/通知适配器,不是调度真相。
|
|
14
|
-
|
|
15
|
-
面向团队成员的 npm CLI 接入说明见 [`docs/onboarding.md`](docs/onboarding.md)。公开 npm 包名为 `xuym-codex-collab`,安装后使用 `codex-collab` 命令:
|
|
7
|
+
需要 Node.js 20+、Git、Codex 桌面应用,以及业务仓库的读取和 push 权限。先 clone 业务仓库;成员无需 clone 本工具源码。
|
|
16
8
|
|
|
17
9
|
```sh
|
|
18
10
|
npm install --global xuym-codex-collab
|
|
19
|
-
codex-collab
|
|
11
|
+
codex-collab setup
|
|
20
12
|
```
|
|
21
13
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
14
|
+
`setup` 打开本机网页工作台,所有接入操作集中在一个页面。管理员选择“初始化团队”,成员选择“加入团队”;已在使用旧配置的成员选择“导入已有配置”。终端保持运行即可继续使用工作台,关闭工作台后已启动的机器人连接仍运行。
|
|
15
|
+
|
|
16
|
+
| 谁 | 在工作台做什么 | 接入后需要保留什么 |
|
|
17
|
+
| --- | --- | --- |
|
|
18
|
+
| 管理员 | 初始化业务仓库;在群里验证自己;分配各成员机器人与职责;下载加密接入包;确认成员申请;按职责设置真人审核规则 | 本机身份、管理员配置和机器人连接 |
|
|
19
|
+
| 成员 | 导入个人接入包与密码;选择自己的业务仓库 clone;在群里发送验证码;等待管理员确认;复制启用指令到选定 Codex 对话 | 本机机器人连接,以及该对话的轮询自动化 |
|
|
20
|
+
|
|
21
|
+
管理员先在企业微信创建**独立智能机器人**并加入协作群,再填写 Bot ID/Secret。工具通过群验证码发现群 `chatid` 和真人 `userid`,不需要成员手工寻找这些 ID、生成密钥或编辑 JSON。机器人 Secret 通过加密接入包交给指定成员,密码单独传递。
|
|
22
|
+
|
|
23
|
+
完整步骤与旧配置迁移见 [团队接入指南](docs/onboarding.md)。
|
|
24
|
+
|
|
25
|
+
## 一次任务如何接力
|
|
26
|
+
|
|
27
|
+
```mermaid
|
|
28
|
+
sequenceDiagram
|
|
29
|
+
participant H as 企业微信群
|
|
30
|
+
participant A as Alice 本机工具
|
|
31
|
+
participant C as Alice 已有 Codex 对话
|
|
32
|
+
participant R as 阶段审核人
|
|
33
|
+
participant G as 业务仓库 agent-collab
|
|
34
|
+
participant B as Bob 已有 Codex 对话
|
|
35
|
+
H->>A: @Alice 协作 创建 [frontend>backend] ...
|
|
36
|
+
A->>G: 签名任务与能力路由
|
|
37
|
+
C->>G: heartbeat 轮询并原子认领
|
|
38
|
+
C->>G: 代码 commit、测试证据、阶段总结
|
|
39
|
+
A->>H: 阶段结果、代码证据与审核命令
|
|
40
|
+
H->>R: 通知本人审核
|
|
41
|
+
R->>A: 通过(或退回并填写修改意见)
|
|
42
|
+
A->>G: 签名审核,通过后开放下一阶段
|
|
43
|
+
B->>G: 从交接 SHA 认领下一阶段
|
|
44
|
+
B->>G: 提交结果,等待真人审核
|
|
29
45
|
```
|
|
30
46
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
**群 @ 后在新的后台 SDK 会话执行(不是已有桌面对话)**:见 [`docs/local-automatic-test.md`](docs/local-automatic-test.md)。新建 automatic 测试环境后,Alice/Bob Runner 都自动执行,按 frontend → backend 路由交接;可安全复用旧私有机器人配置,但启动前必须停旧连接。Bot 对应 Runner 创建的新 Codex SDK 会话,**不是已有桌面对话**。
|
|
34
|
-
|
|
35
|
-
**本机用两个 Codex 对话和两个企业微信机器人手动模拟同事**:见 [`docs/local-peer-test.md`](docs/local-peer-test.md)。`peer:setup-local` 一次生成隔离 demo、副本身份和项目 MCP,不改 CC Switch、不推送 GitHub;第二个对话可用 `claim_desktop_task` 手动接力。本页不自动唤起对话;要让指定已有对话轮询接单,使用 [`docs/desktop-poll.md`](docs/desktop-poll.md)。
|
|
47
|
+
例如在群里选中实际 Alice 机器人发送:
|
|
36
48
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
## Codex 之间的代码接力
|
|
40
|
-
|
|
41
|
-
1. 已绑定身份的 Alice 在桌面 Codex 用 MCP `create_task` 创建任务(或在群里发 `协作 创建 ...`),无需先把任务分配给她;她也可以使用自己已被分配的任务。桌面 Codex 安装 Skill/MCP **不会自动被唤醒**。
|
|
42
|
-
2. Alice 在目标仓库 `codex/*` 分支完成自己的部分,执行可信测试并提交 Git commit;跨机器时还要将分支推送到共同的 `origin`。不要将密钥、聊天全文写入仓库。
|
|
43
|
-
3. Alice 用 `read_task` 获取最新 `state.seq`,调用 MCP `handoff_task`:传 `task_id`、`expected_seq`、`summary`(已完成与下一步)、`branch`、完整 `commit` SHA、真实 `tests` 结果。服务会核实分支 tip 与 commit,写入目标仓库的 `agent-collab` 账本,任务进入 `waiting`。
|
|
44
|
-
4. Alice 作为管理员在同一个企微群 @机器人,发送 `协作 交接 T-... bob`;服务器验证身份后分配任务。Bob 本机持续运行的个人 Runner 领取任务,从交接 commit 建独立 worktree 和新的 Codex SDK 会话;完成后写代码分支、测试证据和 `review_requested`。只有真人审核可批准/完成。
|
|
45
|
-
|
|
46
|
-
目前 `协作 交接` 只路由给一个已配置的企微 Bot;仅在**更新并重启中心服务**后生效。要显示两个不同的机器人身份并分别接受 @ 消息,还需增加同一中心服务的双 Bot 路由;不要运行两个服务并发写同一账本。MCP/Skill 负责桌面 Codex **主动发布交接**,Runner 才负责同事端的**自动领取和新建会话**。
|
|
47
|
-
|
|
48
|
-
## 两个仓库本地测试
|
|
49
|
-
|
|
50
|
-
Node.js >= 20,pnpm 11;在本仓库执行:
|
|
51
|
-
|
|
52
|
-
```sh
|
|
53
|
-
pnpm install --frozen-lockfile
|
|
54
|
-
pnpm test
|
|
49
|
+
```text
|
|
50
|
+
@Alice 协作 创建 [frontend>backend] 给登录页增加密码显示切换 | npm test 通过
|
|
55
51
|
```
|
|
56
52
|
|
|
57
|
-
|
|
53
|
+
Alice 的绑定对话轮询到任务后在隔离 worktree 执行前端阶段,提交代码、运行固定验证命令、写入总结;群里收到分支、提交 SHA、测试结果和审核命令。默认由实际执行者本人审核,通过后才开放 Bob 的后端阶段。退回时必须填写修改意见,当前阶段从被退回的提交继续修改,重新提交后再次审核。管理员可在工作台按职责选择审核人、额外通知对象,或关闭中间阶段审核。候补从 Git 签名事件和租约判断,机器人 @ 消息本身不负责调度。群消息中的验收文字不会替换管理员设置的本机测试 argv。
|
|
58
54
|
|
|
59
|
-
|
|
55
|
+
代码保存在独立 `codex/*` 分支;任务和签名策略保存在 `agent-collab`,两者不自动合并到 `main`。最后阶段始终等待真人审核,审核通过后由管理员完成任务。
|
|
60
56
|
|
|
61
|
-
|
|
57
|
+
审核人在群里 @ 自己绑定的机器人,复制最新通知中的任务 ID 和 `R-版本号`:
|
|
62
58
|
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
COLLAB_ACCOUNTS_FILE=/tmp/collab-accounts.json \
|
|
67
|
-
COLLAB_DATA_DIR=/tmp/collab-service \
|
|
68
|
-
COLLAB_REPO_ID=jack506255560/codex-collab-demo \
|
|
69
|
-
node apps/server/main.mjs
|
|
59
|
+
```text
|
|
60
|
+
协作 审核 T-实际任务ID R-7 通过
|
|
61
|
+
协作 审核 T-实际任务ID R-7 退回 | 请补充键盘操作和相关测试
|
|
70
62
|
```
|
|
71
63
|
|
|
72
|
-
|
|
64
|
+
每次重新提交都产生新的版本号,旧审核命令不能批准新结果。只有指定审核人能审核;通知对象不因此获得审核权限。默认没有本人群身份的测试机器人由管理员审核,方便一个真人模拟两个同事。
|
|
73
65
|
|
|
74
|
-
|
|
75
|
-
COLLAB_REPO=/path/to/codex-collab-demo \
|
|
76
|
-
COLLAB_API=https://your-server \
|
|
77
|
-
COLLAB_TOKEN=<bob的个人令牌> COLLAB_USER_ID=bob \
|
|
78
|
-
COLLAB_DATA_DIR=/path/to/private-runner-data \
|
|
79
|
-
COLLAB_TEST_ARGV='["sh","test.sh"]' \
|
|
80
|
-
node apps/runner/main.mjs
|
|
81
|
-
```
|
|
66
|
+
群通知会列出审核人与通知对象,并尝试用已验证的 `userid` 生成提及。**智能机器人长连接中的原生 @ 提醒仍需实群验证**;不能把收到结果通知等同于已触发原生 @。部分机器人得到的是加密 userid,本工具尚未集成企业应用的明文 ID 转换。群里的人需要先加入同一协作群,并完成自己的接入验证。
|
|
82
67
|
|
|
83
|
-
|
|
68
|
+
## 状态与数据
|
|
84
69
|
|
|
85
|
-
|
|
70
|
+
工作台分别检查项目 MCP/Skill 配置、企微认证、目标对话绑定和实际轮询。机器人认证成功只能说明连接可用;还需要对话真实轮询才能执行任务。MCP 本身不能唤醒闲置对话,“启用指令”会要求在**当前选定对话**建立 heartbeat,应用需支持并运行该自动化。
|
|
86
71
|
|
|
87
|
-
|
|
88
|
-
2. 将 Bot 加到群中,先在本机设置 `FEISHU_APP_ID`、`FEISHU_APP_SECRET` 并运行 `node scripts/discover-feishu.mjs`;在目标群发一条普通消息,终端只打印 `chat_id` 和 `sender_open_id`(不会保存正文),确认所有同事的 open_id 后停止该脚本,再填入账号文件与 `FEISHU_CHAT_IDS` 允许列表。**不要**写死或提交 App Secret。
|
|
89
|
-
3. 启动中心服务时追加:`FEISHU_APP_ID=... FEISHU_APP_SECRET=... FEISHU_CHAT_IDS=oc_...`。看到 WebSocket 订阅日志后,在允许的群由已映射成员发:`协作 创建 写一个功能 | 验收一;验收二`,管理员 `协作 分配 T-... bob`,所有人 `协作 状态 T-...`。Runner 提交审核请求后审核人 `协作 审核 T-... 通过` 或 `协作 审核 T-... 退回`,最后管理员 `协作 完成 T-...`。
|
|
90
|
-
4. 检查群回复、服务日志、目标仓库 `agent-collab` 事件与本机代码分支。未配置飞书凭据时可以跑本地模拟 E2E;模拟通过**不等于**真实群接通。
|
|
72
|
+
成员私钥在成员本机生成。Git 记录公钥、成员职责、签名邀请/申请、任务事件、代码引用、测试和阶段总结;Secret、对话 ID、运行令牌及聊天全文留在本机。团队新增成员或修改审核规则后会发布新的签名策略版本;旧任务继续遵循创建时的策略,避免改写已运行任务的权限和候补。
|
|
91
73
|
|
|
92
|
-
|
|
74
|
+
停止工作台或机器人连接不会自动暂停 Codex heartbeat;需要暂停协作时,应在对应对话暂停自动化,并检查在执行任务的状态。
|
|
93
75
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
当前单机测试目录可以这样找(不要打印里面的令牌或环境文件):
|
|
76
|
+
## 开发与兼容
|
|
97
77
|
|
|
98
78
|
```sh
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
# 仅在你的本机交互终端输入;不要把实际值放在命令行参数或聊天中。
|
|
103
|
-
printf 'Bot ID: '; IFS= read -r WECOM_BOT_ID
|
|
104
|
-
printf '新 Bot Secret(不回显): '; IFS= read -rs WECOM_BOT_SECRET; echo
|
|
105
|
-
export WECOM_BOT_ID WECOM_BOT_SECRET
|
|
106
|
-
node scripts/discover-wecom.mjs
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
等待输出 `WeCom authenticated`,在目标群里向机器人发送文本,例如 `@机器人 协作 状态 T-NONE`;发现脚本仅输出 `{"chatid":"...","userid":"..."}`,**不会输出正文**。这一步如果只有认证而没有群消息,不代表群已接通:检查群是否已加入机器人、消息触发方式和企业管理员配置。在发现脚本按 `Ctrl-C` 关闭连接,再继续下面步骤;**同一个机器人不要让发现脚本和服务同时建立连接**。至少记下一个真实群 `chatid` 及你(管理员 alice)的 `userid`,若同事要在群里发命令,再收集其 `userid`。不要猜 ID、不要贴到 Git。以下命令的引号和交互输入要保留:
|
|
110
|
-
|
|
111
|
-
```sh
|
|
112
|
-
printf '刚发现的群 chatid: '; IFS= read -r WECOM_CHAT_IDS
|
|
113
|
-
printf '你自己的 userid(alice): '; IFS= read -r ALICE_WECOM_USERID
|
|
114
|
-
export WECOM_CHAT_IDS
|
|
115
|
-
node scripts/bind-wecom-users.mjs "$root/accounts.json" "$root/accounts.wecom.json" "alice:$ALICE_WECOM_USERID"
|
|
116
|
-
# 可选:另一位真人需在群里操作时,用他的 userid 重建私有文件;不要把两人的 userid 填成同一个。
|
|
117
|
-
# node scripts/bind-wecom-users.mjs "$root/accounts.json" "$root/accounts.team.json" "alice:$ALICE_WECOM_USERID" 'bob:<同事的真实userid>'
|
|
118
|
-
# 新文件只保留原有的个人 token 哈希,原有 Runner 令牌不会变化。
|
|
119
|
-
. "$root/private.env"
|
|
120
|
-
export COLLAB_ACCOUNTS_FILE="$root/accounts.wecom.json"
|
|
121
|
-
# 仅停原中心服务,保持两个 Runner;先确认旧 PID 确实是本工具的服务。
|
|
122
|
-
old="$(cat "$root/server.pid")"
|
|
123
|
-
ps -p "$old" -o command=
|
|
124
|
-
# 仅在确认该进程属于当前仓库的 apps/server/main.mjs 后,才手动执行:kill "$old"
|
|
125
|
-
node apps/server/main.mjs
|
|
79
|
+
pnpm install --frozen-lockfile
|
|
80
|
+
npm test
|
|
81
|
+
node bin/codex-collab.mjs setup
|
|
126
82
|
```
|
|
127
83
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
1. `协作 创建 给 greet.sh 增加 --upper 行为 | sh test.sh 通过;sh greet.sh --upper 输出 HELLO, WORLD;保留原有默认输出`,记下新任务 `T-...`;不要重复使用历史任务。
|
|
131
|
-
2. `协作 分配 T-... bob`;本机 Bob Runner 自动领取并在隔离副本创建 `codex/task-*` 代码分支。Alice Runner 可读取审查任务并写私有审查记录。
|
|
132
|
-
3. `协作 状态 T-...`;人工核对 `git -C "$root/demo" branch --list 'codex/task-*'`、`sh test.sh`(需要在**任务分支**的 worktree 执行;`main` 故意未完成,不能直接把它的失败当任务失败)和 `$root/server-data` 的 Git 账本;不能把 Runner 记录当人工批准。
|
|
133
|
-
4. 确认代码和测试证据后,由 **alice 真人**发 `协作 审核 T-... 通过`,再发 `协作 完成 T-...`;这两个动作不会由 Runner 自动执行。失败就 `协作 审核 T-... 退回`。
|
|
134
|
-
|
|
135
|
-
**注意**:发现、认证、模拟 E2E、真实群消息送达、群回复、主动任务通知是不同的成功条件;逐项核实。这里使用 `$root/demo` 隔离副本,不修改或推送原始 demo 仓库。`COLLAB_PUSH` 单机保持默认关闭;源仓库与真实企业账号信息勿放入 Git。若之前本机 Runner 不在运行,查看 `ps` 与 `apps/runner/README.md`;不要以为启动 HTTP 服务就会自动启动 Runner。
|
|
136
|
-
|
|
137
|
-
## 同事接入 MCP 和 Skill
|
|
84
|
+
保留 `codex-collab setup --terminal`、`setup --role admin|member` 和原有 `start` / `stop` / `status` 等命令。终端模式沿用旧 `team.json` 工作流,网页向导使用签名团队版本与加密个人接入包,初始化 v3 人工审核策略。旧 v2 团队导入后仍保留原行为;保存审核规则才升级,升级前请先更新所有成员的工具并重启机器人和 MCP。不会改写全局 Codex、CC Switch 或模型账号设置。
|
|
138
85
|
|
|
139
|
-
|
|
86
|
+
- [接入、迁移和首次验收](docs/onboarding.md)
|
|
87
|
+
- [团队签名协议与目录](docs/team-registry.md)
|
|
88
|
+
- [Peer 机制与底层命令](docs/peer.md)
|
|
89
|
+
- [已有桌面对话轮询机制](docs/desktop-poll.md)
|
|
90
|
+
- [旧中心服务与飞书接入](docs/legacy-center.md)
|
|
140
91
|
|
|
141
|
-
|
|
92
|
+
2026-10-10 已由用户验证本机真实企业微信群 + Alice/Bob 两个已有 Codex 对话的接力闭环。网页向导与真人审核闭环通过临时 Git 仓库、模拟机器人和实际浏览器操作验证;2026-10-12 这轮测试没有连接真实机器人,原生 @ 尚未验证。跨两台机器的部署尚未验证。
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { SetupError } from './errors.mjs';
|
|
2
|
+
import { randomBytes, scryptSync, createCipheriv, createDecipheriv } from 'node:crypto';
|
|
3
|
+
export function encryptBundle(value, password) {
|
|
4
|
+
if (typeof password !== 'string' || password.length < 12 || password.length > 256) throw new SetupError('接入包密码至少 12 个字符,请单独交给成员。');
|
|
5
|
+
const salt = randomBytes(16), iv = randomBytes(12);
|
|
6
|
+
const cipher = createCipheriv('aes-256-gcm', scryptSync(password, salt, 32), iv);
|
|
7
|
+
const ciphertext = Buffer.concat([cipher.update(JSON.stringify(value), 'utf8'), cipher.final()]);
|
|
8
|
+
return { schema_version: 1, kind: 'codex-collab-encrypted-invite', salt: salt.toString('base64'), iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), ciphertext: ciphertext.toString('base64') };
|
|
9
|
+
}
|
|
10
|
+
export function decryptBundle(value, password) {
|
|
11
|
+
try {
|
|
12
|
+
if (value?.schema_version !== 1 || value.kind !== 'codex-collab-encrypted-invite' || typeof password !== 'string' || password.length > 256) throw new Error();
|
|
13
|
+
const salt = Buffer.from(value.salt, 'base64'), iv = Buffer.from(value.iv, 'base64'), tag = Buffer.from(value.tag, 'base64');
|
|
14
|
+
if (salt.length !== 16 || iv.length !== 12 || tag.length !== 16 || typeof value.ciphertext !== 'string' || value.ciphertext.length > 500000) throw new Error();
|
|
15
|
+
const decipher = createDecipheriv('aes-256-gcm', scryptSync(password, salt, 32), iv); decipher.setAuthTag(tag);
|
|
16
|
+
return JSON.parse(Buffer.concat([decipher.update(Buffer.from(value.ciphertext, 'base64')), decipher.final()]).toString('utf8'));
|
|
17
|
+
} catch { throw new SetupError('接入包或密码无效,请确认文件完整且密码正确。'); }
|
|
18
|
+
}
|
|
19
|
+
export function remoteIdentity(remote) {
|
|
20
|
+
const scp = remote.match(/^(?:[^@/]+@)?([^:/]+):(.+)$/);
|
|
21
|
+
try { const url = new URL(remote); if (['https:', 'http:', 'ssh:'].includes(url.protocol)) return `${url.hostname.toLowerCase()}/${url.pathname.replace(/^\/+|\/+$/g, '').replace(/\.git$/, '')}`; } catch {}
|
|
22
|
+
if (scp && !remote.startsWith('/')) return `${scp[1].toLowerCase()}/${scp[2].replace(/\/+$/g, '').replace(/\.git$/, '')}`;
|
|
23
|
+
return remote;
|
|
24
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { SetupError } from './errors.mjs';
|
|
2
|
+
import AiBot from '@wecom/aibot-node-sdk';
|
|
3
|
+
import { randomInt, randomUUID } from 'node:crypto';
|
|
4
|
+
import { wecomDiagnostic } from '../../scripts/wecom-diagnostic.mjs';
|
|
5
|
+
|
|
6
|
+
/** Short-lived connection; credentials and group messages never enter HTTP output. */
|
|
7
|
+
export class BotDiscovery {
|
|
8
|
+
constructor({ clientFactory = (options) => new AiBot.WSClient(options), now = Date.now } = {}) {
|
|
9
|
+
this.clientFactory = clientFactory; this.now = now; this.sessions = new Map();
|
|
10
|
+
}
|
|
11
|
+
begin({ bot_id, secret, chat_ids }) {
|
|
12
|
+
if (typeof bot_id !== 'string' || !bot_id.trim() || typeof secret !== 'string' || !secret) throw new SetupError('请填写机器人 Bot ID 和 Secret。');
|
|
13
|
+
this.closeAll();
|
|
14
|
+
const id = randomUUID(); const code = String(randomInt(100000, 1000000));
|
|
15
|
+
const state = { id, code, bot_id: bot_id.trim(), secret, expected_chats: chat_ids, expires: this.now() + 300000, status: 'connecting', proof: null, client: null, timer: null };
|
|
16
|
+
const logger = { debug() {}, info() {}, warn() {}, error() {} };
|
|
17
|
+
const client = this.clientFactory({ botId: state.bot_id, secret, logger }); state.client = client;
|
|
18
|
+
client.on('authenticated', () => { state.status = 'authenticated'; });
|
|
19
|
+
client.on('error', (error) => { state.status = 'failed'; state.error = wecomDiagnostic(error); });
|
|
20
|
+
client.on('disconnected', () => { if (!state.proof && !['expired', 'failed'].includes(state.status)) state.status = 'disconnected'; });
|
|
21
|
+
client.on('message.text', (frame) => {
|
|
22
|
+
if (this.now() > state.expires || state.proof || state.status !== 'authenticated') return;
|
|
23
|
+
const body = frame?.body;
|
|
24
|
+
const text = body?.text?.content?.trim().replace(/^(?:@[^\s@]{1,40}\s+)+/, '');
|
|
25
|
+
if (body?.chattype !== 'group' || !body.chatid || !body.from?.userid || text !== `协作 接入 ${code}` || state.expected_chats && !state.expected_chats.includes(body.chatid)) return;
|
|
26
|
+
state.proof = { chatid: body.chatid, userid: body.from.userid }; state.status = 'verified';
|
|
27
|
+
client.disconnect();
|
|
28
|
+
});
|
|
29
|
+
state.timer = setTimeout(() => { if (!state.proof) { state.status = 'expired'; client.disconnect(); } }, 300000); state.timer.unref();
|
|
30
|
+
this.sessions.set(id, state); client.connect(); return this.view(id);
|
|
31
|
+
}
|
|
32
|
+
view(id) {
|
|
33
|
+
const state = this.sessions.get(id); if (!state) throw new SetupError('验证连接已结束,请重新连接。');
|
|
34
|
+
if (this.now() > state.expires && !state.proof) { state.status = 'expired'; state.client.disconnect(); }
|
|
35
|
+
return { id, status: state.status, message: `协作 接入 ${state.code}`, proof: state.proof, ...(state.error ? { error: state.error } : {}) };
|
|
36
|
+
}
|
|
37
|
+
consume(id) {
|
|
38
|
+
this.view(id); const state = this.sessions.get(id);
|
|
39
|
+
if (!state.proof || this.now() > state.expires) throw new SetupError('请先在群里完成机器人验证。');
|
|
40
|
+
this.sessions.delete(id); clearTimeout(state.timer); state.client.disconnect();
|
|
41
|
+
return { bot_id: state.bot_id, secret: state.secret, ...state.proof };
|
|
42
|
+
}
|
|
43
|
+
closeAll() { for (const state of this.sessions.values()) { clearTimeout(state.timer); state.client.disconnect(); } this.sessions.clear(); }
|
|
44
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export class SetupError extends Error {}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdir, readFile, rm } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { startSetupServer } from './server.mjs';
|
|
6
|
+
import { SetupManager } from './manager.mjs';
|
|
7
|
+
import { alive } from '../peer/process.mjs';
|
|
8
|
+
function openBrowser(url) {
|
|
9
|
+
const command = process.platform === 'darwin' ? ['open', url] : process.platform === 'win32' ? ['cmd', '/c', 'start', '', url] : ['xdg-open', url];
|
|
10
|
+
const child = spawn(command[0], command.slice(1), { stdio: 'ignore' }); child.on('error', () => {}); child.unref();
|
|
11
|
+
}
|
|
12
|
+
export async function runLocalSetup({ open = true, home = join(homedir(), '.config/codex-collab/workspace') } = {}) {
|
|
13
|
+
home = resolve(home); await mkdir(home, { recursive: true, mode: 0o700 });
|
|
14
|
+
const path = join(home, 'wizard.json'); let lock;
|
|
15
|
+
try { lock = await (await import('node:fs/promises')).open(path, 'wx', 0o600); }
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (error.code !== 'EEXIST') throw error;
|
|
18
|
+
let previous; try { previous = JSON.parse(await readFile(path, 'utf8')); } catch { throw new Error('本机向导正在初始化,请稍后重试。'); }
|
|
19
|
+
if (alive(previous.pid)) { console.log(`本机向导已运行:${previous.url}`); if (open) openBrowser(previous.url); return; }
|
|
20
|
+
await rm(path, { force: true }); return runLocalSetup({ open, home });
|
|
21
|
+
}
|
|
22
|
+
let app;
|
|
23
|
+
try {
|
|
24
|
+
app = await startSetupServer({ manager: new SetupManager({ home }) });
|
|
25
|
+
await lock.writeFile(JSON.stringify({ pid: process.pid, url: app.url }) + '\n'); await lock.close();
|
|
26
|
+
console.log(`Codex Collab 本机接入向导:${app.url}\n保持此终端运行。Ctrl-C 关闭向导;已启动的机器人 Runner 会继续运行。`);
|
|
27
|
+
if (open) openBrowser(app.url);
|
|
28
|
+
const stop = async () => { await app.close(); await rm(path, { force: true }); process.exit(0); };
|
|
29
|
+
process.once('SIGINT', stop); process.once('SIGTERM', stop);
|
|
30
|
+
return app;
|
|
31
|
+
} catch (error) { await lock.close().catch(() => {}); await rm(path, { force: true }); if (app) await app.close(); throw error; }
|
|
32
|
+
}
|