Compare commits

..

2 Commits

Author SHA1 Message Date
zenord fe7bbd0631 Simplify instance lifecycle commands 2026-08-17 00:49:29 +08:00
zenord f9fb4ef776 Refactor runtime around isolated bot instances 2026-08-16 23:45:09 +08:00
52 changed files with 2581 additions and 1537 deletions
+206 -292
View File
@@ -1,353 +1,267 @@
# gori-agent 项目施工指南
本文件供 Kimi Code 等 Coding Agent 在本仓库中施工时使用。目标是让后续 Agent 先理解架构和配置契约,再做最小、可验证的修改。
本文件供 Coding Agent 在本仓库施工时使用。先理解 Config v3 和实例隔离契约,再做最小、可验证的修改。
## 1. 项目定位
## 1. 项目定位与固定边界
`gori-agent` 是一个 Node.js 20+ / TypeScript 项目,通过 IM 接收用户请求,并通过官方 Agent Client Protocol(ACP)驱动 Coding Agent。
当前主链路:
`gori-agent` 是 Node.js 20+ / TypeScript 项目,通过单个 IM 平台接收请求,并通过官方 Agent Client Protocol(ACP)驱动一个 Coding Agent。
```text
用户
→ QQ / 其他 IM
→ Platform Adapter
→ Gateway
→ AcpSessionManager
→ AcpWorker
→ ACP Coding Agent(默认 Kimi Code)
→ 工具、脚本和工作目录
→ 原路返回用户
用户 → Platform Adapter → Gateway → AcpSessionManager → AcpWorker → ACP Agent
```
当前运行时只使用 ACP。不要重新引入“一句话启动一次 CLI”的单轮调用方式,也不要把 legacy `CliAgent` 当作运行时 fallback。
每个运行实例是一个完整且固定的 Bot:
模型和 provider 属于 ACP 后端自身的配置。例如 Kimi Code 使用其自己的 `config.toml`;`gori-agent/config.json` 只配置如何启动 ACP backend,不负责复制或管理模型凭据。
- 一个 Gateway 进程。
- 一份 Config v3 本地配置。
- 一个 `bot.id`、workspace、persona。
- 一个 ACP agent。
- 一组直接声明的 skills 与一个 permission policy。
- 一个平台身份。
- 独立 PID、日志和 ACP state。
## 2. 核心设计原则
不要重新引入 Config v1/v2 migration、`roles[]`、动态 role selection、`backends[]`、多平台同时启用或单轮 CLI fallback。
- **Role 是业务身份**:定义职责、workspace、skills 和权限策略。
- **Backend 是执行后端**:定义 ACP 子进程的 command、args 和 env。
- **Skill 是可注入知识**:顶层声明 SKILL.md,role 按 ID 引用。
- **Session 是连续对话**:同一 `platform + chat + role` 复用同一 native ACP session。
- **权限由 ACP policy 约束**:persona 不是安全边界,不能替代 permission policy。
- **默认 fail closed**:不确定时拒绝权限,不自动扩大工具或命令范围。
- **一个进程只登录一个 QQ Bot**:多 Bot 使用多个进程和独立配置、端口、状态目录。
## 2. 实例目录契约
## 3. 代码结构
统一实例根:
- `src/server.ts`
- 组装 store、role registry、ACP session manager、Gateway 和平台 adapter。
- 挂载 health/platform/webhook 路由,按配置启动 QQ WebSocket。
- `src/config.ts`
- config v2 的 Zod schema、默认值、交叉引用校验和 v1 Kimi 配置迁移。
- 新增配置字段时,必须先更新 schema,再更新模板、setup/doctor、测试和文档。
- `src/core/gateway.ts`
- 入站 allowlist、群聊 mention 规则、命令分发、per-chat 串行锁和回复发送。
- `/cancel` 必须能绕过 chat lock,避免无法取消长任务。
- `src/core/command-router.ts`
- `/help`、`/roles`、`/role`、`/status`、`/cancel`、`/new`。
- `src/core/durable-session-store.ts`
- 持久化 role 选择和 ACP session binding。
- 使用单 writer lock 和原子写入;不要绕过或手改运行中的 state。
- `src/roles/role-registry.ts`
- 读取 role、加载 skill、生成 fingerprint 和隐藏 bootstrap prompt。
- `src/roles/skill-loader.ts`
- 校验 skill 文件、大小限制并计算内容 hash。
- `src/acp/client.ts`
- ACP initialize、session new/resume/load、prompt、cancel 和 permission request。
- `src/acp/worker.ts`
- 在 `role.workspace` 中启动 ACP 子进程,处理超时、取消和异常退出。
- `src/acp/session-manager.ts`
- 管理 chat/role binding、worker 池、冷恢复、idle sweep、容量淘汰和 reset。
- `src/platforms/qq/`
- QQ WebSocket、webhook、验签、消息标准化和发送。
- `src/cli.ts`、`src/cli/`
- setup、doctor、status 和 backend discovery。
- `src/agents/`、`src/core/session-store.ts`
- legacy compatibility/reference,不是当前运行链路。除非需求明确,不要在这里扩展新能力。
- `dist/`
- TypeScript 构建产物。只修改 `src/`,不要手改 `dist/`。
```text
${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances/<bot-id>/
├── config.json
├── logs/gori-agent.log
└── state/
├── acp-sessions.json
└── gori-agent.pid
```
## 4. Config v2 契约
实例目录本身就是该进程的 `GORI_AGENT_HOME`。约束:
唯一可提交的配置模板是 `config.example.json`。真实部署配置复制为本地 `config.json`:
- `bot.id` 只允许小写字母、数字和连字符,拒绝 `/`、`..` 等路径穿越。
- CLI 加载后必须校验目录名与 `config.bot.id` 相同。
- 实例目录、`logs/`、`state/` 为 `0700`。
- `config.json`、PID、state 为 `0600`。
- 多实例必须使用不同 `gateway.server.port` 和平台 credentials。
- `init` / `setup` 必须交互询问并校验 server host/port;扫描其他 Config v3 的声明端口,冲突时警告,新实例建议下一个未声明端口,已有配置不得静默改端口。
- `setup <bot-id>` 只重配已有实例:校验目录/config/PID,运行中或 foreign PID 拒绝,固定 Bot ID,保留 agent/skills/permissions/policy/runtime/secrets/publicBaseUrl,确认写入后运行 doctor。
- setup 的声明冲突提示不替代实际 bind 检查;`start` 对缺配置、ID 不匹配、错误权限、占位符、运行 PID、端口冲突 fail closed。
- start 只 spawn 构建后的内部 `dist/cli/instance-runner.js <config-path>`;PID identity 必须精确匹配 Node executable、runner 和唯一 config 参数。
公开入口仅有:
```bash
cp config.example.json config.json
chmod 600 config.json
gori-agent init <bot-id>
gori-agent setup <bot-id>
gori-agent start <bot-id>
gori-agent stop <bot-id>
gori-agent restart <bot-id>
gori-agent status <bot-id>
gori-agent logs <bot-id>
gori-agent doctor <bot-id>
gori-agent list
```
`config.json` 和备份包含 IM credentials,不得提交。
不兼容旧 `instance` 前缀;不公开 debug 命令、`--config` 或 `--json`。
### 4.1 顶层字段
## 3. 核心代码结构
- `src/config.ts`
- Config v3 Zod schema、类型、交叉校验和 state 默认路径。
- 只接受 `configVersion: 3`。
- `src/server.ts`
- 组装 fixed Bot、state store、ACP manager、Gateway 和唯一 platform adapter。
- 只挂载所选平台 route。
- `src/roles/role-registry.ts`
- 历史路径名保留,但实现是 `BotProfileResolver`,不是 role registry。
- 加载当前 Bot skills、计算 fingerprint、生成版本化 bootstrap。
- `src/acp/client.ts`
- ACP initialize/new/resume/load/prompt/cancel 和 permission request。
- `src/acp/worker.ts`
- 直接使用 resolved Bot 的 agent,在 `bot.workspace` 启动。
- `src/acp/session-manager.ts`
- 固定 Bot 的 binding、worker pool、恢复、idle sweep、capacity、cancel/reset。
- `src/core/durable-session-store.ts`
- state v2,保存 bot/platform identity 和 chat binding。
- 单 writer lock、串行持久化、临时文件、fsync、原子 rename。
- `src/core/gateway.ts`
- 入站 allowlist、群 mention、命令、per-chat lock 和回复。
- `/cancel` 必须绕过 chat lock。
- `src/core/command-router.ts`
- `/help`、`/status`、`/cancel`、`/new`;旧 role 命令返回 retired 提示。
- `src/platforms/*`
- Adapter 依赖独立平台 config type,不依赖完整 AppConfig 路径。
- `src/cli/config-file.ts`
- fail-closed 加载、example seed 与原子 `0600` 配置写入。
- `src/cli/instance.ts`
- 实例目录、PID/log、端口和 lifecycle 管理。
- `src/cli/instance-runner.ts`
- 非公开进程入口;加载单实例配置、启动 server,并处理 SIGINT/SIGTERM 优雅关闭。
- `src/cli/doctor.ts`
- v3、Bot、agent、platform、state、permission 和 placeholder 检查。
- `src/agents/*`、`src/core/session-store.ts`
- legacy compatibility/reference,不是当前运行链路。
- `dist/`
- 构建产物;不手改,修改 TypeScript 后运行 `npm run build`。
## 4. Config v3 契约
仓库中唯一可提交配置说明是 `config.example.json`。它含明显占位符,可通过 schema,但不能启动实际 Bot。
顶层:
```text
configVersion 必须为 2
server HTTP 服务配置
policy IM 入站访问策略
acp ACP 生命周期、状态和进程池配置
backends[] ACP 后端启动定义,至少一个
skills[] 可选 SKILL.md 定义
defaultRole 默认 role ID
roles[] 业务角色定义,至少一个
platforms QQ、Feishu、WeCom、Webhook、Weixin 配置
configVersion 固定 3
bot 固定 Bot、agent、skills、permissions
gateway server、入站 policy、唯一 platform
runtime.acp state 和 ACP 生命周期
```
Zod 会剥离 schema 未声明的字段。因此不能只在 JSON 中增加字段而不修改 `src/config.ts`。
### 4.2 `server`
- `host`:默认 `0.0.0.0`。
- `port`:1–65535;多实例必须使用不同端口。
- `publicBaseUrl`:反向代理或公开 webhook 基础地址;不需要时为空。
### 4.3 顶层 `policy`
- `allowedUsers: string[]`:空数组表示不按用户限制;非空时精确匹配。
- `allowedChats: string[]`:空数组表示不按会话限制;非空时精确匹配。
- `requireMentionInGroup: boolean`:群聊是否必须 @bot。
- QQ 群 chat ID 形式为 `group:<group_openid>`。
- 正式运维 Bot 应配置 allowlist,不要长期保持完全开放。
### 4.4 `acp`
- `stateFile`:空字符串时使用 `$GORI_AGENT_HOME/state/acp-sessions.json`。
- `initializeTimeoutMs`:ACP 初始化超时。
- `promptTimeoutMs`:单轮 prompt 超时。
- `cancelGraceMs`:取消后等待时间,超时才终止 worker。
- `idleTimeoutMs`:空闲 worker 回收时间;session binding 仍可持久化恢复。
- `sweepIntervalMs`:空闲扫描周期。
- `maxProcesses`:最大 ACP 子进程数。
不同实例不得共享 `stateFile` 或 `GORI_AGENT_HOME`。持久 store 有单 writer lock,共享会使第二个实例启动失败。
### 4.5 `backends[]`
每项字段:
### 4.1 `bot`
```json
{
"id": "kimi",
"command": "/home/USER/.kimi-code/bin/kimi",
"args": ["acp"],
"env": {}
}
```
约束:
- `id` 必须唯一。
- `command` 必须是可执行程序。
- Kimi Code backend 应使用 `args: ["acp"]`。
- 子进程以 `shell: false`、`cwd: role.workspace` 启动。
- `env` 会覆盖同名进程环境变量;不得把 token 或 API key 写入可提交模板。
- 新增其他 Coding Agent 前,必须确认它提供兼容 ACP,或提供独立且有测试的 ACP adapter;不要假设普通 CLI 等同 ACP。
### 4.6 `skills[]`
每项字段:
```json
{
"id": "SKILL_ID",
"file": "/absolute/path/to/SKILL.md",
"maxBytes": 256000
}
```
约束:
- `id` 必须唯一。
- 建议使用绝对路径,避免不同启动目录导致解析变化。
- 文件必须存在、可读、是普通文件且不超过 `maxBytes`。
- Role 只能引用已在顶层声明的 skill ID。
- Skill 内容会发送给 ACP backend,也参与 role fingerprint;不要在 skill 中放 secret。
### 4.7 `roles[]`
推荐一个 Bot 实例只保留它自己的一个 role:
```json
{
"id": "ROLE_ID",
"backend": "kimi",
"workspace": "/absolute/path/to/workspace",
"persona": "明确描述职责、边界和何时停止请求确认。",
"id": "bot-id",
"workspace": "/absolute/path",
"persona": "明确职责、边界和确认点。",
"agent": {
"id": "kimi",
"command": "/absolute/path/to/kimi",
"args": ["acp"],
"env": {}
},
"skills": [],
"policy": {
"permissionMode": "deny",
"permissions": {
"mode": "deny",
"allowedTools": [],
"allowedCommandPatterns": []
}
}
```
约束:
- workspace 必须绝对。
- 一个 Bot 只有一个 agent;不要添加 registry/reference。
- Kimi Code 应使用 `args: ["acp"]`。
- agent `shell: false`,cwd 为 workspace。
- 模型/provider 属于 ACP agent 自己的配置,不写入 gori-agent config。
- `env` 不得在模板、日志、diff 中泄漏 token。
- Role `id` 必须唯一,`defaultRole` 必须引用存在的 role。
- `workspace` 必须是绝对路径,并应在 `doctor` 时真实存在。
- `backend` 和 `skills` 引用必须存在。
- 修改 role ID、backend、workspace、persona、policy 或 skill 内容会改变 fingerprint;下一条消息会创建新 native session,避免沿用旧身份上下文。
### 4.2 Skills
权限模式:
- `deny`:拒绝所有 ACP permission request;新角色默认使用此模式。
- `allowlist`:只允许 `allowedTools`;bash/terminal 还必须匹配至少一个 `allowedCommandPatterns` 正则。
- `auto`:自动允许,等价于高风险/yolo;只有用户明确要求、workspace 与 Bot 访问范围都可控时才能配置。
配置 `allowlist` 时:
- 使用最小工具集合。
- 命令正则应锚定开头并限制参数,不要使用 `.*` 放行所有命令。
- 删除、部署、推送、发送消息等不可逆或外部操作仍应在 persona 中要求确认。
- permission request 信息不足时必须拒绝,不能猜测。
### 4.8 `platforms.qq`
`bot.skills[]` 每项直接声明:
```json
{
"enabled": true,
"connectionMode": "websocket",
"appId": "QQ_APP_ID",
"clientSecret": "QQ_CLIENT_SECRET",
"botSecret": "",
"verifySignature": true,
"botNames": ["QQ_BOT_NAME"],
"intents": 33554432,
"shard": [0, 1]
}
{ "id": "skill-id", "file": "/absolute/SKILL.md", "maxBytes": 256000 }
```
- WebSocket 登录需要有效的 `appId` 和 `clientSecret`。
- Webhook 验签使用 `botSecret || clientSecret`。
- `botNames` 用于 mention 识别,应与平台上的机器人名称一致。
- 一个 config 只有一个 QQ 对象,因此一个进程只能登录一个 QQ Bot。
- 不要把 QQ 注销、群注销、无权限等平台 4xx 错误误判成 ACP session 错误;先检查错误码、目标 chat/user openid 和消息发送 endpoint。
ID 唯一;文件须存在、可读、普通文件且未超限。skill 内容发送给 ACP,也进入 fingerprint;不得放 secret。
其他平台字段以 `src/config.ts` schema 为准。平台 `enabled` 不一定会统一禁用所有 HTTP 路由;公开 webhook 时仍需检查路由和验签实现。
### 4.3 Permissions
## 5. Role 与 Session 行为
- `deny`:默认,拒绝全部 permission request。
- `allowlist`:`toolCall.name` 与 `allowedTools` 精确匹配;bash/terminal 的 raw command 还要被 `allowedCommandPatterns` 完整覆盖。
- `auto`:高风险/yolo,只有用户明确授权才能设置。
- Binding key 是 `platform + chatId + roleId`。
- 同一 chat 的不同 role 拥有不同 native ACP session。
- 首次 prompt 会创建 session、发送隐藏 bootstrap,成功后保存 binding,再发送用户请求。
- Gateway 或 ACP worker 重启后,使用持久 binding 尝试 `session/resume`,必要时回退 `session/load`。
- `/new` 删除当前 chat/role binding;下一条普通消息再创建新 session。
- `/cancel` 取消当前 turn,不应等待正在执行的 chat lock。
- Prompt 失败不能擅自重放,因为工具操作可能已经产生副作用。
- Session 上下文由 native ACP Agent 维护;gori-agent 维护角色选择、binding、生命周期和恢复信息,不应每轮把完整历史重新拼给 CLI。
permission policy 只审批 ACP request,不会注册工具。persona 不是安全边界。请求信息不足时拒绝。命令 regex 应锚定并限制参数,不使用任意 `.*` 全放行。
## 6. 多机器、多 Bot 部署规范
### 4.4 Gateway
每个 Bot 使用一份位于部署机器上的本地 `config.json`,不要在仓库中创建 `config.ops.json`、`config.developer.json` 等带真实凭据的文件。
- `gateway.server`: host、port、publicBaseUrl。
- `gateway.policy`: allowedUsers、allowedChats、requireMentionInGroup。
- `gateway.platform`: `type` discriminated union,只能是 qq、feishu、wecom、webhook、weixin 之一。
- 对象存在即启用,无 `enabled` 字段。
每个实例至少要唯一:
QQ 群 chat ID 为 `group:<group_openid>`。正式运维 Bot 应配置入口 allowlist。
- QQ `appId`、`clientSecret`、`botNames`
- `server.port`
- `GORI_AGENT_HOME`,或显式且独立的 `acp.stateFile`
- role ID、workspace、persona 和 policy
### 4.5 Runtime ACP
示例:
- `stateFile` 为空时为 `$GORI_AGENT_HOME/state/acp-sessions.json`。
- `initializeTimeoutMs`: 默认 10000。
- `promptTimeoutMs`: 默认 7200000(2 小时)。
- `cancelGraceMs`: 默认 5000。
- `idleTimeoutMs`: 默认 1800000。
- `sweepIntervalMs`: 默认 60000。
- `maxProcesses`: 默认 8。
## 5. Session 与 state v2
Binding key 是当前实例固定的 `platform + chatId`。不再包含 role。
state v2 header 保存 `botId` 和 platform。打开时不匹配必须拒绝,不能清空、迁移或覆盖。旧 state v1 也明确拒绝并保留原文件。
Binding 保存 agent ID、workspace、native session ID、Bot fingerprint 和时间戳。fingerprint 包含:
- bootstrap schema version
- Bot ID
- workspace/persona
- agent 定义
- permission policy
- skill 路径和内容 hash
首次消息创建 native session,发送隐藏 bootstrap,成功后保存 binding。重启/idle 后优先 resume,再 fallback load。prompt 失败不重放。
`/new` 删除当前 chat binding;下一条消息创建新 session。`/cancel` 不等待 chat lock。
## 6. 单平台装配
Server 只构造 `gateway.platform.type` 对应 adapter,只挂载对应 webhook:
- qq webhook 模式 → `/webhook/qq`;QQ WebSocket 模式不挂载该 route
- feishu → `/webhook/feishu`
- wecom → `/webhook/wecom`
- weixin → `/webhook/weixin`
- webhook → `/webhook/generic`
QQ WebSocket 仅在 qq + websocket 模式启动。`GET /health` 只输出 configVersion、botId、platform 和 aggregate ACP counters,不得输出 credentials 或 native session ID。`GET /platforms` 返回单 Bot/platform 概览。
## 7. 配置与实例施工流程
1. 读取本文件、`config.example.json` 和 `src/config.ts`。
2. 明确 Bot ID、workspace、persona、平台、skills 和最小 permission policy。
3. 确认 ACP agent executable 与 ACP 可用。
4. 使用 `gori-agent init <bot-id>` 或 Coding Agent 生成实例 `config.json`;不要把 example 当运行配置。
5. 交互确认并严格校验 `gateway.server.host/port`;对其他实例的声明端口冲突给出警告和建议,但仍由 `start` 做实际 bind 检查。
6. 写配置必须同目录临时文件 + fsync + atomic rename,最终 `0600`;目录 `0700`。
7. setup 的 secret/token 输入必须不回显;secrets 不在对话、日志、测试输出、diff 中回显。
8. 运行 `gori-agent doctor <bot-id>`,不发送真实平台消息。
9. 只有用户明确授权时才启动/停止真实 Bot。
旧仓库本地 `config.json`、备份和 state 可供人工回退;改造实例时不要删除或覆盖。
## 8. 测试与验收
行为变化补测试,不削弱测试。配置 schema 变化同步:
- `src/config.ts`
- `config.example.json`
- setup/doctor/instance CLI
- README 和本文件
- config、session、store、Gateway、server 测试
最终运行:
```bash
GORI_AGENT_HOME=/home/USER/.gori-agent-ROLE_ID \
./gori-agent.sh start-daemon --config ./config.json
```
`GORI_AGENT_HOME` 隔离:
- PID
- 日志
- 默认 ACP session state
- 单实例锁
同一机器启动多个实例时,每个实例应从各自部署目录运行,或明确指定独立配置路径和 `GORI_AGENT_HOME`。
## 7. 配置施工流程
当用户让 Agent 配置一个新角色或新 Bot 时,按以下顺序执行:
1. 读取 `AGENTS.md`、`config.example.json` 和相关 schema;不要先读取或展示真实 secrets。
2. 明确 role 的职责、workspace、所需 skills、允许的工具和命令。
3. 确认 backend 可执行路径以及 `kimi acp` 可用。
4. 从唯一模板复制本地 `config.json`,不另建可提交的具体 Bot 模板。
5. 填写本机 QQ credentials;不要在回复、日志、diff 或测试输出中回显。
6. 默认先用 `deny`;需要施工能力时配置最小 `allowlist`;只有明确授权才使用 `auto`。
7. 设置文件权限为 `0600`。
8. 运行配置和 backend 检查:
```bash
./gori-agent.sh discover-backends --config ./config.json
./gori-agent.sh doctor --config ./config.json
```
9. 前台启动,确认 QQ WebSocket ready,再从 IM 执行只读验收。
10. 前台通过后,使用唯一 `GORI_AGENT_HOME` 后台启动。
11. 任何包含 secret 的配置、备份和日志都不得加入 Git。
若缺少真实凭据,可以完成无密钥模板和规范,但不得虚构 credentials,也不得声称真实 QQ 登录已验证。
## 8. 常用命令
```bash
npm install
npm run typecheck
npm test
npm run build
gori-agent setup --config ./config.json
gori-agent discover-backends --config ./config.json
gori-agent doctor --config ./config.json
gori-agent start --config ./config.json
./gori-agent.sh start --config ./config.json
./gori-agent.sh start-daemon --config ./config.json
./gori-agent.sh status --config ./config.json
./gori-agent.sh logs
./gori-agent.sh stop
git diff --check
bash -n install.sh gori-agent.sh bin/gori-agent
```
`gori-agent.sh` 不会检测源码是否比 `dist/` 新。修改 TypeScript 后必须显式运行 `npm run build`。
额外检查:
## 9. 修改与验收规则
- example 可 parse,doctor 识别 placeholder。
- 不存在的运行配置明确失败。
- tracked diff 无 config、backup、state、log、credentials 或手改 dist。
- 不执行真实平台消息、pull、部署、远程机器修改或 Git mutation,除非用户当次明确授权。
- 做最小、局部、可审查的修改,不做无关重构。
- 先修改 `src/`,不要直接修改 `dist/`。
- 行为变化应补对应测试;不要通过削弱测试来适配实现。
- 配置 schema 变化必须同步:
- `src/config.ts`
- `config.example.json`
- setup/doctor(如适用)
- README 和本文件
- 配置测试
- 平台错误必须保留足够的错误码和 trace ID 用于诊断,但不得输出 secret。
- 修改后运行:
```bash
npm run typecheck
npm test
npm run build
git diff --check
```
- 测试未通过时不能宣称完成。
- 不执行真实 QQ 消息、pull、部署、push、创建 PR 等外部操作,除非用户明确要求并授权。
- 不自动执行 Git commit/push;需要时按用户当次指令处理。
## 10. 敏感信息与禁止提交内容
## 9. 敏感信息与禁止提交
不得提交或粘贴:
- `config.json`
- `config.json.*.bak`
- 任意包含真实 Bot/App credentials 的自定义配置
- `.env`
- `backends[].env` 中的 token/API key
- ACP state、PID、lock 和 session metadata
- 日志中的真实 chat ID、user ID、message ID、消息正文或 ACP stderr 敏感内容
- 任何真实 `config.json` 或备份
- `.env`、agent env token/API key
- platform credentials
- ACP state、PID、lock、session metadata
- 含真实 chat/user/message ID 或消息正文的日志
唯一可提交配置模板:`config.example.json`。其中只能使用明显占位符。
当前 `.gitignore` 已覆盖 `config.json`、备份、日志、`dist/`、`node_modules/` 和 coverage。新增其他具体配置文件名时,不要依赖命名约定判断安全;只要包含真实凭据,就必须留在仓库外或明确忽略。
`.gitignore` 已覆盖仓库本地 `config.json`、备份、日志、`dist`、`node_modules` 和 coverage。实例默认在仓库外。安全性按内容判断,不能只依赖文件名。
+187 -288
View File
@@ -1,16 +1,45 @@
# gori-agent
通过 QQ 等 IM 调用 ACP Coding Agent。当前默认后端是 Kimi Code:
`gori-agent` 是一个 Node.js 20+ / TypeScript 网关:从一个 IM 平台接收消息,通过官方 Agent Client Protocol(ACP)驱动一个 Coding Agent。
```text
用户 → QQ → gori-agent Gateway → ACP → Kimi Code → 工具/脚本 → QQ
用户 → 单个平台 Adapter → Gateway → ACP Session Manager → 单个 ACP Agent
```
## 五分钟上手
## Config v3 实例模型
### 1. 准备 Kimi Code
Config v3 只支持以下边界:
确认 Kimi Code 已登录且 ACP 可用:
- 一份运行配置对应一个固定 Bot 身份。
- 一个 Bot 只有一个 workspace、persona、ACP agent、skill 列表和 permission policy。
- 一个实例只绑定一个平台;多平台使用多个实例。
- 不再有 `roles[]`、`defaultRole`、`backends[]`、动态 `/role` 切换或 Config v1/v2 迁移。
- `configVersion` 非 `3` 会明确失败,旧 state v1 也不会自动改写。
一台机器上的实例统一位于:
```text
${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances/<bot-id>/
├── config.json
├── logs/gori-agent.log
└── state/
├── acp-sessions.json
└── gori-agent.pid
```
实例目录就是该进程的 `GORI_AGENT_HOME`。实例目录、`state/`、`logs/` 使用 `0700`,配置与状态文件使用 `0600`。
## 构建与安装
```bash
npm install
npm run build
./install.sh
```
安装器只安装一个 launcher;不会为每个 Bot 复制源码或 `dist/`。默认 launcher 位于 `$HOME/.gori-agent/bin/gori-agent`。
确认 ACP agent 可用:
```bash
kimi --version
@@ -18,331 +47,201 @@ kimi doctor
kimi acp --help
```
ACP 使用 `~/.kimi-code/config.toml` 中的 `default_model`。模型切换应先在 Kimi Code 中完成,再重启 gori-agent;已有聊天要使用新模型时,在 QQ 中发送 `/new` 创建新 session。
模型和 provider 由 ACP agent 自身配置,例如 Kimi Code 使用 `~/.kimi-code/config.toml`。gori-agent 不复制模型凭据。
### 2. 构建并迁移配置
## 实例管理
```bash
cd /home/ubuntu/gori-space/gori-agent
npm install
npm run build
./gori-agent.sh setup --config ./config.json
gori-agent init <bot-id>
gori-agent setup <bot-id>
gori-agent doctor <bot-id>
gori-agent start <bot-id>
gori-agent status <bot-id>
gori-agent logs <bot-id>
gori-agent restart <bot-id>
gori-agent stop <bot-id>
gori-agent list
```
建议向导选择:
- `init` 交互生成 Config v3,本身不启动 Bot;它会询问并校验 `gateway.server.host/port`,扫描统一实例目录中其他 Config v3 的声明端口,并在默认端口冲突时建议下一个未占用端口。
- `setup` 只重配已有实例。它校验目录、配置身份、配置/PID 权限,并拒绝运行中实例或指向其他活进程的 PID;固定 `bot.id`,保留既有 agent(含 args/env)、skills、permissions、gateway policy、runtime、平台 secrets 与 publicBaseUrl,确认写入后自动运行 doctor。
- 平台 secret/token 使用不回显输入,空 generic webhook/weixin secret 会随机生成。
- `start` 会检查配置存在、目录名匹配 `bot.id`、目录/配置权限、模板占位符、workspace、agent executable、PID identity 和端口,并等待 `/health` 返回匹配的 Bot/platform identity;任一不满足即 fail closed。
- 实例进程由内部 `dist/cli/instance-runner.js` 承载;`status` 和 lifecycle 命令精确核对 Node executable、runner 路径及唯一 config 参数,再核对 `/health` 的 Bot/platform identity。
- 每个实例必须使用不同的 `gateway.server.port` 和平台凭据;setup 的声明冲突提示不替代 `start` 的实际端口检查。
- `stop` 发送 `SIGTERM`,runner 会优雅关闭 server,并等待最多 10 秒;不会自动 `SIGKILL`。
公开 CLI 不兼容旧 `instance` 前缀,也不提供 debug 命令、`--config` 或 `--json`。
可用 `GORI_AGENT_ROOT` 改变统一根目录:
```bash
GORI_AGENT_ROOT=/srv/gori-agent gori-agent list
```
## `config.example.json` 的定位
`config.example.json` 是仓库内唯一可提交、无密钥的配置说明,不是运行配置。它可被 Config v3 schema 解析,但保留 `BOT_ID`、`QQ_APP_ID` 等明显占位符。只有 `init` 会读取 example 作为初始化种子;其他命令只按 `<bot-id>` 加载统一实例目录中的 `config.json`,缺失即失败。
`writeConfigFile()` 使用同目录临时文件、`fsync` 和原子 rename,并强制最终文件为 `0600`。
## Config v3
完整说明见 `config.example.json`。顶层只有:
```text
Assistant workspace: /home/ubuntu/gori-space/gori-agent
Include ops role with the existing gori-update skill: yes
Write config: yes
configVersion 固定为 3
bot Bot、workspace、persona、agent、skills、permissions
gateway HTTP server、入站 policy、唯一 platform
runtime.acp ACP state、timeout 和 worker pool
```
迁移会把旧的 `kimi -p` 改为 `kimi acp`,并完整保留已有 QQ appId、secret 和连接模式。
### 3. 检查配置
```bash
./gori-agent.sh discover-backends --config ./config.json
./gori-agent.sh doctor --config ./config.json
```
预期看到 Kimi ACP `ready`,并支持 session `load/resume`。
### 4. 前台启动并从 QQ 验收
```bash
./gori-agent.sh start --config ./config.json
```
启动日志应包含:
```text
QQ websocket connected
QQ websocket ready
```
在 QQ 群中 @bot 并发送:
```text
/status
/roles
/role assistant
只读回答当前工作目录,不要修改文件
```
运维角色先做只读测试:
```text
/role ops
/status
请只读取并概述 gori-update skill,不要 pull、构建或部署
```
常用聊天命令:
```text
/roles 列出角色
/role <id> 切换角色
/status 查看当前 role/workspace/session 状态
/cancel 取消正在执行的任务
/new 为当前角色创建新的 Agent session
```
同一个 `平台 + chat + role` 会持续使用同一个 Kimi session;Gateway 或 ACP 子进程重启后会恢复。不同 role 的 session 相互独立。
### 5. 后台运行
前台验收通过后:
```bash
export GORI_AGENT_HOME=/home/ubuntu/.gori-agent
./gori-agent.sh start-daemon --config ./config.json
./gori-agent.sh status --config ./config.json
./gori-agent.sh logs
./gori-agent.sh stop
```
运行状态和日志位于:
```text
/home/ubuntu/.gori-agent/state/
/home/ubuntu/.gori-agent/logs/gori-agent.log
```
`config.json` 包含 IM secret,不要提交,建议执行:
```bash
chmod 600 config.json
```
## 多机器与多 QQ Bot
仓库只维护一个无密钥模板:`config.example.json`。在每台机器、每个 Bot 实例中复制后单独修改:
```bash
cp config.example.json config.json
chmod 600 config.json
```
每个进程只能登录一个 QQ Bot。为每个实例配置唯一的 QQ `appId`、`clientSecret`、`botNames` 和 `server.port`,并使用不同的 `GORI_AGENT_HOME`,避免日志、PID 和 ACP session 状态相互覆盖:
```bash
GORI_AGENT_HOME=/home/USER/.gori-agent-ROLE_ID \
./gori-agent.sh start-daemon --config ./config.json
```
建议每个 Bot 的 `roles[]` 只保留它自己的一个 role,同时修改 `ROLE_ID`、`workspace`、`persona` 和 `policy`。需要 skill 时,再向顶层 `skills[]` 添加对应 SKILL.md,并在 role 的 `skills` 中引用。各机器的 `config.json` 不要提交到仓库。
## 配置角色
角色定义在 `config.json` 的 `roles[]` 中:
### Bot 与 ACP agent
```json
{
"id": "ops",
"backend": "kimi",
"workspace": "/home/ubuntu/gori-space",
"persona": "你是 Gori 团队运维角色,严格遵循 gori-update skill。",
"skills": ["gori-update"],
"policy": {
"permissionMode": "allowlist",
"allowedTools": ["read", "grep", "glob", "bash"],
"allowedCommandPatterns": ["允许的命令正则"]
}
}
```
- `backend`:ACP 后端 ID,默认 `kimi`。
- `workspace`:该角色的工作目录,必须是绝对路径。
- `persona`:角色职责。
- `skills`:引用顶层 `skills[]` 中的 SKILL.md。
- `permissionMode`:`deny` 全拒绝;`allowlist` 按工具和命令规则放行;`auto` 全放行(高风险)。
修改 persona、workspace、skill 内容或 policy 后,角色 fingerprint 会变化;下一条消息会创建新的 native session,避免沿用旧角色上下文。
## 配置 QQ
`config.json` 的最小 QQ websocket 配置:
```json
{
"policy": {
"allowedUsers": [],
"allowedChats": [],
"requireMentionInGroup": true
},
"platforms": {
"qq": {
"enabled": true,
"connectionMode": "websocket",
"appId": "你的 AppID",
"clientSecret": "你的 ClientSecret",
"botSecret": "",
"verifySignature": true,
"botNames": ["你的机器人名称"],
"intents": 33554432,
"shard": [0, 1]
"bot": {
"id": "my-bot",
"workspace": "/absolute/workspace",
"persona": "Describe responsibilities, boundaries, and confirmation points.",
"agent": {
"id": "kimi",
"command": "/home/USER/.kimi-code/bin/kimi",
"args": ["acp"],
"env": {}
},
"skills": [],
"permissions": {
"mode": "deny",
"allowedTools": [],
"allowedCommandPatterns": []
}
}
}
```
初次测试可让 `allowedUsers` 和 `allowedChats` 为空;正式运维 bot 应限制到指定用户或群。群聊 chat ID 的格式为 `group:<group_openid>`。
- `bot.id` 只允许小写字母、数字和连字符,最长 63 字符。
- `workspace` 与每个 skill `file` 必须是绝对路径。
- `bot.skills[]` 直接声明 `{ id, file, maxBytes }`;ID 必须唯一。
- agent 使用 `shell: false` 在 `bot.workspace` 启动。
- agent env 可能包含 secret,不能进入可提交模板、日志或 diff。
---
### Permission policy
## Detailed reference
ACP permission request 由 `bot.permissions` 决策,persona 不是安全边界:
Multi-IM gateway that drives coding agents through the official Agent Client Protocol (ACP):
- `deny`:拒绝所有 permission request,默认值。
- `allowlist`:`toolCall.name` 必须与 `allowedTools` 精确匹配;bash/terminal 还必须由至少一个 `allowedCommandPatterns` 正则完整覆盖整条 raw command。名称或 command 缺失时拒绝。
- `auto`:自动允许,风险等价于 yolo;`doctor` 会告警。
```text
IM Adapter -> Gateway -> Role/Session Manager -> ACP Client -> kimi acp
```
`allowedTools` 只控制 ACP 审批,不会注册或创造工具。skills、agent 与 permission policy 是三个独立概念。
The MVP backend is Kimi Code ACP. Gateway code contains no Kimi CLI prompt fallback and no Pi/Codex private protocol. Future agents must enter through an ACP adapter such as `codex-acp` or a Pi ACP adapter.
### 单平台配置
## Requirements and quick start
`gateway.platform` 是按 `type` 区分的 union,只能选择一个:
Requires Node.js 20+ and an authenticated Kimi Code installation.
- `qq`:WebSocket 或 webhook。
- `feishu`:`/webhook/feishu`。
- `wecom`:`/webhook/wecom`;入站仍是 501 scaffold。
- `webhook`:`/webhook/generic`,同步 JSON + HMAC-SHA256。
- `weixin`:`/webhook/weixin`,外部 bridge scaffold。
```bash
npm install
npm run build
./install.sh
gori-agent setup
gori-agent doctor --config ./config.json
gori-agent start --config ./config.json
```
对象存在即启用,不再使用 `enabled`。Server 只构造所选 adapter,也只挂载需要的 webhook route。QQ WebSocket 仅在 `type=qq` 且 `connectionMode=websocket` 时启动,此模式不挂载 `/webhook/qq`。
`setup` probes ACP backends, creates an assistant role, optionally creates the `ops` role, preserves all existing platform settings, prints a migration summary, and only writes after confirmation. A v1 config is accepted at runtime only when its default agent is Kimi; other CLI agents require an explicit ACP backend.
Convenience wrapper commands:
```bash
./gori-agent.sh start
./gori-agent.sh start-daemon
./gori-agent.sh status
./gori-agent.sh logs
./gori-agent.sh stop
```
`stop` sends SIGTERM and waits up to 10 seconds for graceful shutdown. For production, use systemd with `Restart=always` and `KillMode=control-group` so an unexpected gateway crash also cleans up ACP children.
## CLI
```bash
gori-agent setup [--config path]
gori-agent discover-backends [--json]
gori-agent start [--config path]
gori-agent status [--config path]
gori-agent doctor [--config path]
gori-agent print feishu [--config path]
```
`discover-agents` remains a deprecated alias for `discover-backends`. Discovery does not send a prompt. Kimi is ready when `kimi acp` is available; Codex and Pi report `needs-adapter` unless their ACP adapter executable exists.
## Roles, sessions, and commands
A conversation binding is keyed by `platform + chatId + role`. Each binding stores the ACP backend, native session ID, workspace, and role fingerprint. The selected role and bindings survive gateway restart.
- A new session receives a hidden bootstrap prompt containing persona, workspace, skill content, and policy. The binding is saved only after bootstrap succeeds.
- A role persona, workspace, policy, or skill-content change changes its fingerprint and causes a new native session.
- Idle workers are stopped and later cold-resumed with `session/resume` (or `session/load` when resume is unavailable).
- Failed prompts are never replayed automatically.
- `/new` cancels the current turn, unbinds the current chat/role, and creates a native session on the next prompt. It does not delete Kimi history.
- `/cancel` bypasses the per-chat lock and sends ACP `session/cancel`; an unresponsive worker is force-recycled after the grace period.
Chat commands:
- `/help`
- `/roles`
- `/role <id>`
- `/status`
- `/cancel`
- `/new`
- `/agents` and `/agent <id>` (deprecated aliases)
Messages in one chat are serialized; different chats run concurrently.
## Configuration v2
See `config.example.json`. Important sections:
- `backends[]`: ACP spawn command and arguments. The default is `/home/ubuntu/.kimi-code/bin/kimi acp`.
- `roles[]`: backend, absolute workspace, persona, skills, and permission policy.
- `skills[]`: readable skill files with a byte limit. Skill contents participate in the role fingerprint.
- `acp.stateFile`: defaults to `$GORI_AGENT_HOME/state/acp-sessions.json`.
- `acp.initializeTimeoutMs`, `promptTimeoutMs`, `cancelGraceMs`: request lifecycle limits.
- `acp.idleTimeoutMs`, `sweepIntervalMs`, `maxProcesses`: worker pool limits.
- `policy.allowedUsers`, `allowedChats`, `requireMentionInGroup`: inbound IM policy.
- `platforms`: Feishu, WeCom, QQ, generic webhook, and Weixin settings. Migration preserves this object, including credentials.
State writes use a serial promise queue, temporary file, fsync, and atomic rename. A single-instance lock protects the state file. Corrupt state is preserved and startup fails explicitly instead of overwriting it.
## Permission policy
ACP permission requests are decided by role policy, not by persona:
- `deny`: reject every permission request.
- `allowlist`: require both an allowed tool name and, for bash/terminal, a matching raw command pattern. If the ACP request lacks enough command detail, it is denied.
- `auto`: approve everything; `doctor` prints a high-risk warning.
The example `ops` role loads the existing read-only skill file at `/home/ubuntu/gori-space/gori-deploy/.kimi-code/skills/gori-update/SKILL.md`. Its allowlist covers selected `git`, build/deploy entry scripts, and read-only Docker status/log commands. `sudo`, force push, and arbitrary deletion are not allowed. Script-internal operations cannot be inspected by ACP once an explicitly allowed deployment script starts, so script paths must remain trusted.
Kimi 0.36.1 does not advertise a generic model config option during ACP initialize; this MVP uses the model configured as Kimi's default and `doctor` reports that limitation.
## Platform behavior
| Platform | Inbound | Outbound | Notes |
| --- | --- | --- | --- |
| Feishu/Lark | Implemented | Implemented | `im.message.receive_v1` and message reply API. |
| WeCom | 501 scaffold | Implemented | Inbound verification/encryption is not implemented. |
| Weixin | External webhook scaffold | Synchronous | Native personal WeChat is not included. |
| QQ | Implemented | Implemented | WebSocket gateway and HTTP callback modes. |
| Generic webhook | Implemented | Synchronous JSON | HMAC-SHA256 signed JSON. |
QQ WebSocket mode uses intent `33554432` for `GROUP_AT_MESSAGE_CREATE` and `C2C_MESSAGE_CREATE`. The adapter supports top-level and nested `author.user_openid` fields, preserves callback ACK `{ "op": 12 }` even if ACP work later fails, and logs receive/send routing. No external QQ message is sent by the automated tests.
Endpoints:
- `GET /health` — aggregate ACP counters only; native session IDs are not exposed.
- `GET /platforms`
- `POST /webhook/feishu`
- `POST /webhook/wecom`
- `POST /webhook/qq`
- `POST /webhook/generic`
- `POST /webhook/weixin`
Generic webhook payload:
QQ websocket 示例:
```json
{
"chat_id": "demo-chat",
"user_id": "demo-user",
"text": "/status",
"message_id": "optional",
"is_group": false,
"mentions_bot": true
"type": "qq",
"connectionMode": "websocket",
"appId": "QQ_APP_ID",
"clientSecret": "QQ_CLIENT_SECRET",
"botSecret": "",
"verifySignature": true,
"botNames": ["QQ_BOT_NAME"],
"intents": 33554432,
"shard": [0, 1]
}
```
When `platforms.webhook.secret` is set, provide `X-Gori-Signature: sha256=<HMAC-SHA256 hex>` over the exact JSON body.
正式 Bot 应通过 `gateway.policy.allowedUsers` / `allowedChats` 限制入口。QQ 群 chat ID 为 `group:<group_openid>`。
## Lifecycle and diagnostics
### ACP 生命周期
Shutdown order is QQ WebSocket stop, active ACP cancellation, worker termination, state flush/unlock. SIGINT and SIGTERM share the same idempotent shutdown path. Health statistics include active workers, in-flight turns, worker crashes, persisted bindings, and locked chats.
默认 `runtime.acp.promptTimeoutMs` 是 `7200000`(2 小时),适合长构建/部署任务。其他默认值:
`doctor` checks role/workspace/skill validity, ACP initialize and restore capabilities, state directory access, permission warnings, configured platforms, and QQ requirements. It performs no prompt and no external QQ test.
- initialize:10 秒
- cancel grace:5 秒
- idle worker:30 分钟
- sweep:60 秒
- max processes:8
## Development
`stateFile` 为空时使用当前实例的 `$GORI_AGENT_HOME/state/acp-sessions.json`。
## Session 与 state v2
每个绑定由当前实例固定的 `platform + chatId` 唯一确定。state v2 header 保存 `botId` 与 platform type;打开 state 时身份不匹配会拒绝启动,避免错误复用另一个实例目录。
Binding 保存:
- chat key
- agent ID
- native ACP session ID
- workspace
- Bot fingerprint
- 创建与更新时间
Bot fingerprint 包含 Bot ID、workspace、persona、agent、permissions、skill 路径/内容 hash 和 bootstrap schema version。任一语义变化后,下一条消息创建新 native session,不恢复旧身份上下文。
首次 prompt 会创建 native session,发送隐藏 bootstrap,bootstrap 成功后才保存 binding。worker 空闲回收后优先 `session/resume`,否则回退 `session/load`。失败 prompt 不会自动重放,因为工具操作可能已有副作用。
旧 state v1 会被原样保留并明确拒绝;使用新的实例目录开始 state v2,不要手工改写运行中的 state。
## IM 命令
```text
/help
/status
/cancel
/new
```
- `/status` 显示固定 Bot、agent、workspace 和 session persisted/running 状态。
- `/cancel` 绕过 per-chat lock,发送 ACP cancel。
- `/new` 清除当前 chat binding;下一条普通消息创建新 native session。
- `/roles`、`/role`、`/agents`、`/agent` 会返回固定 retired 提示,不会转发给 ACP。
同一 chat 串行执行,不同 chat 可并发。
## HTTP 端点
- `GET /health`:`configVersion`、`botId`、platform 和 aggregate ACP counters,不暴露凭据/native session ID。
- `GET /platforms`:当前单一 Bot/platform 概览。
- `POST /webhook/<selected-platform>`:只存在所选平台路由;generic 使用 `/webhook/generic`。
## Doctor 与安全纪律
`gori-agent doctor <bot-id>` 检查:
- Config v3 与 example placeholder。
- 配置权限 `0600`。
- Bot ID、workspace、skills 与 permissions。
- ACP initialize 与 session restore capability。
- state 目录可读写。
- 单平台必需字段,但不打印 credential 值。
自动测试不会启动真实 Bot、发送平台消息、pull、部署或访问远程机器。真实 `config.json`、备份、state 和日志不得提交。
## 开发验收
```bash
npm run typecheck
npm test
npm run build
git diff --check
bash -n install.sh gori-agent.sh bin/gori-agent
```
Tests use Node `node:test` through `tsx`. The fake ACP subprocess covers initialize/new/resume/prompt/cancel, persistence, idle cold resume, Gateway commands, and QQ normalization/ACK behavior.
测试使用 Node `node:test` + `tsx` 和本地 fake ACP 子进程,覆盖 Config v3、permission、bootstrap、timeout/cancel、冷恢复、fingerprint mismatch、state v2 identity、原子配置/state 写入、Gateway retired commands、QQ normalization 和单平台 route。
Legacy `src/agents/*` source remains only for compatibility/reference and is not imported by Gateway or Server. There is no runtime `CliAgent` fallback.
`src/agents/*` 与 `src/core/session-store.ts` 仅为 legacy compatibility/reference,不在当前运行链路。运行时没有单轮 CLI fallback。
+36 -63
View File
@@ -1,64 +1,35 @@
{
"configVersion": 2,
"server": {
"host": "0.0.0.0",
"port": 8787,
"publicBaseUrl": ""
},
"policy": {
"allowedUsers": [],
"allowedChats": [],
"requireMentionInGroup": true
},
"acp": {
"stateFile": "",
"initializeTimeoutMs": 10000,
"promptTimeoutMs": 600000,
"cancelGraceMs": 5000,
"idleTimeoutMs": 1800000,
"sweepIntervalMs": 60000,
"maxProcesses": 8
},
"backends": [
{
"configVersion": 3,
"bot": {
"id": "BOT_ID",
"workspace": "/absolute/path/to/workspace",
"persona": "Describe this bot's responsibilities and boundaries.",
"agent": {
"id": "kimi",
"command": "/home/USER/.kimi-code/bin/kimi",
"args": ["acp"],
"env": {}
}
],
"skills": [],
"defaultRole": "ROLE_ID",
"roles": [
{
"id": "ROLE_ID",
"backend": "kimi",
"workspace": "/absolute/path/to/workspace",
"persona": "Describe this agent's responsibilities and boundaries.",
"skills": [],
"policy": {
"permissionMode": "deny",
"allowedTools": [],
"allowedCommandPatterns": []
}
}
],
"platforms": {
"feishu": {
"enabled": false,
"appId": "",
"appSecret": "",
"verificationToken": "",
"botNames": []
},
"wecom": {
"enabled": false,
"corpId": "",
"agentId": "",
"secret": ""
"skills": [],
"permissions": {
"mode": "deny",
"allowedTools": [],
"allowedCommandPatterns": []
}
},
"gateway": {
"server": {
"host": "0.0.0.0",
"port": 8787,
"publicBaseUrl": ""
},
"qq": {
"enabled": true,
"policy": {
"allowedUsers": [],
"allowedChats": [],
"requireMentionInGroup": true
},
"platform": {
"type": "qq",
"connectionMode": "websocket",
"appId": "QQ_APP_ID",
"clientSecret": "QQ_CLIENT_SECRET",
@@ -67,15 +38,17 @@
"botNames": ["QQ_BOT_NAME"],
"intents": 33554432,
"shard": [0, 1]
},
"webhook": {
"enabled": false,
"secret": ""
},
"weixin": {
"enabled": false,
"mode": "external-webhook",
"secret": ""
}
},
"runtime": {
"acp": {
"stateFile": "",
"initializeTimeoutMs": 10000,
"promptTimeoutMs": 7200000,
"cancelGraceMs": 5000,
"idleTimeoutMs": 1800000,
"sweepIntervalMs": 60000,
"maxProcesses": 8
}
}
}
+22 -134
View File
@@ -2,157 +2,45 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GORI_AGENT_HOME="${GORI_AGENT_HOME:-$ROOT_DIR}"
STATE_DIR="$GORI_AGENT_HOME/state"
LOG_DIR="$GORI_AGENT_HOME/logs"
CONFIG_FILE="${GORI_GATEWAY_CONFIG:-$ROOT_DIR/config.json}"
PID_FILE="$STATE_DIR/gori-agent.pid"
LOG_FILE="$LOG_DIR/gori-agent.log"
usage() {
cat <<EOF
Usage: $0 <command> [--config path]
cat <<'EOF'
Usage: gori-agent <command> [arguments]
Commands:
setup Run interactive setup
start Start gateway in foreground
start-daemon Start gateway in background
stop Stop background gateway
restart Restart background gateway
status Show gateway status
doctor Check configuration, roles, and ACP backends
logs Follow gateway log
discover-backends List detected ACP backends
discover-agents Deprecated alias for discover-backends
init <bot-id>
setup <bot-id>
start <bot-id>
stop <bot-id>
restart <bot-id>
status <bot-id>
logs <bot-id>
doctor <bot-id>
list
Environment:
GORI_GATEWAY_CONFIG Config path, default: $CONFIG_FILE
Instances live under ${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances/<bot-id>.
config.example.json is documentation only and is never used as runtime config.
EOF
}
ensure_build() {
if [[ ! -f "$ROOT_DIR/dist/cli.js" ]]; then
echo "dist/cli.js not found; building..."
if [[ ! -f "$ROOT_DIR/dist/cli.js" || ! -f "$ROOT_DIR/dist/cli/instance-runner.js" ]]; then
echo "CLI build output not found; building..."
npm run build --prefix "$ROOT_DIR"
fi
}
ensure_state() {
mkdir -p "$STATE_DIR" "$LOG_DIR"
}
is_running() {
[[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null
}
start_daemon() {
ensure_build
ensure_state
if is_running; then
echo "gori-agent already running, pid $(cat "$PID_FILE")"
exit 0
fi
nohup node "$ROOT_DIR/dist/cli.js" start --config "$CONFIG_FILE" >>"$LOG_FILE" 2>&1 &
echo $! >"$PID_FILE"
echo "gori-agent started, pid $(cat "$PID_FILE")"
echo "log: $LOG_FILE"
}
stop_daemon() {
if ! is_running; then
rm -f "$PID_FILE"
echo "gori-agent is not running"
return 0
fi
local pid
pid="$(cat "$PID_FILE")"
kill "$pid"
local waited=0
while kill -0 "$pid" 2>/dev/null && [[ $waited -lt 100 ]]; do
sleep 0.1
waited=$((waited + 1))
done
if kill -0 "$pid" 2>/dev/null; then
echo "gori-agent did not stop gracefully within 10 seconds, pid $pid" >&2
return 1
fi
rm -f "$PID_FILE"
echo "gori-agent stopped, pid $pid"
}
if [[ $# -lt 1 ]]; then
if [[ $# -eq 0 ]]; then
usage
exit 1
fi
COMMAND="$1"
shift
while [[ $# -gt 0 ]]; do
case "$1" in
--config)
CONFIG_FILE="$2"
shift 2
;;
--help|-h)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
case "$COMMAND" in
setup)
ensure_build
exec node "$ROOT_DIR/dist/cli.js" setup --config "$CONFIG_FILE"
;;
start)
ensure_build
exec node "$ROOT_DIR/dist/cli.js" start --config "$CONFIG_FILE"
;;
start-daemon)
start_daemon
;;
stop)
stop_daemon
;;
restart)
stop_daemon
start_daemon
;;
status)
ensure_build
if is_running; then
echo "gori-agent process: running, pid $(cat "$PID_FILE")"
else
echo "gori-agent process: not running"
fi
exec node "$ROOT_DIR/dist/cli.js" status --config "$CONFIG_FILE"
;;
doctor)
ensure_build
exec node "$ROOT_DIR/dist/cli.js" doctor --config "$CONFIG_FILE"
;;
discover-backends|discover-agents)
ensure_build
exec node "$ROOT_DIR/dist/cli.js" "$COMMAND" --config "$CONFIG_FILE"
;;
logs)
ensure_state
touch "$LOG_FILE"
exec tail -f "$LOG_FILE"
;;
--help|-h|help)
case "$1" in
--help|-h)
usage
;;
*)
echo "Unknown command: $COMMAND" >&2
usage >&2
exit 1
exit 0
;;
esac
ensure_build
exec node "$ROOT_DIR/dist/cli.js" "$@"
+11 -8
View File
@@ -2,19 +2,22 @@
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GORI_AGENT_HOME="${GORI_AGENT_HOME:-$HOME/.gori-agent}"
BIN_DIR="$GORI_AGENT_HOME/bin"
GORI_AGENT_ROOT="${GORI_AGENT_ROOT:-$HOME/.gori-agent}"
BIN_DIR="$GORI_AGENT_ROOT/bin"
INSTANCES_DIR="$GORI_AGENT_ROOT/instances"
PROFILE_FILE="${GORI_AGENT_PROFILE:-$HOME/.bashrc}"
mkdir -p "$BIN_DIR" "$GORI_AGENT_HOME/state" "$GORI_AGENT_HOME/logs"
umask 077
mkdir -p "$BIN_DIR" "$INSTANCES_DIR"
chmod 700 "$GORI_AGENT_ROOT" "$BIN_DIR" "$INSTANCES_DIR"
cat >"$BIN_DIR/gori-agent" <<EOF
#!/usr/bin/env bash
set -euo pipefail
export GORI_AGENT_HOME="\${GORI_AGENT_HOME:-$GORI_AGENT_HOME}"
export GORI_AGENT_ROOT="\${GORI_AGENT_ROOT:-$GORI_AGENT_ROOT}"
exec "$PROJECT_ROOT/gori-agent.sh" "\$@"
EOF
chmod +x "$BIN_DIR/gori-agent"
chmod 700 "$BIN_DIR/gori-agent"
PATH_BLOCK_START="# >>> gori-agent >>>"
PATH_BLOCK_END="# <<< gori-agent <<<"
@@ -22,7 +25,7 @@ if [[ -w "$PROFILE_FILE" ]] && ! grep -q "$PATH_BLOCK_START" "$PROFILE_FILE"; th
{
echo ""
echo "$PATH_BLOCK_START"
echo "export GORI_AGENT_HOME=\"$GORI_AGENT_HOME\""
echo "export GORI_AGENT_ROOT=\"$GORI_AGENT_ROOT\""
echo "export PATH=\"$BIN_DIR:\$PATH\""
echo "$PATH_BLOCK_END"
} >>"$PROFILE_FILE"
@@ -30,5 +33,5 @@ fi
echo "Installed gori-agent to $BIN_DIR/gori-agent"
echo "Project root: $PROJECT_ROOT"
echo "PATH profile: $PROFILE_FILE"
echo "Run 'source $PROFILE_FILE' or open a new terminal, then use: gori-agent --help"
echo "Instances root: $INSTANCES_DIR"
echo "Run 'source $PROFILE_FILE' or open a new terminal, then use: gori-agent list"
+1 -7
View File
@@ -1,7 +1,7 @@
{
"name": "gori-agent",
"version": "0.1.0",
"description": "Hermes-style multi-IM gateway driving coding agents through ACP.",
"description": "Single-Bot, single-platform gateway driving one coding agent through ACP.",
"type": "module",
"main": "dist/server.js",
"bin": {
@@ -10,13 +10,7 @@
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"start": "node dist/server.js",
"dev": "tsx src/server.ts",
"gori-agent": "node dist/cli.js",
"setup": "node dist/cli.js setup",
"discover-backends": "node dist/cli.js discover-backends",
"discover-agents": "node dist/cli.js discover-agents",
"doctor": "node dist/cli.js doctor",
"test": "tsx --test test/*.test.ts"
},
"engines": {
+6 -17
View File
@@ -1,23 +1,12 @@
import type { AcpBackendConfig } from "../config.js";
import type { AgentConfig } from "../config.js";
import type { AcpBackendSpec } from "./types.js";
// Legacy compatibility wrapper; Config v3 runtime uses bot.agent directly.
export class AcpBackendRegistry {
private readonly backends = new Map<string, AcpBackendSpec>();
constructor(configs: AcpBackendConfig[]) {
for (const config of configs) this.register({ ...config });
}
register(backend: AcpBackendSpec): void {
if (this.backends.has(backend.id)) throw new Error(`Duplicate ACP backend: ${backend.id}`);
this.backends.set(backend.id, backend);
}
constructor(private readonly agent: AgentConfig) {}
get(id: string): AcpBackendSpec {
const backend = this.backends.get(id);
if (!backend) throw new Error(`Unknown ACP backend: ${id}`);
return backend;
if (id !== this.agent.id) throw new Error(`Unknown ACP agent: ${id}`);
return { ...this.agent };
}
list(): string[] { return [...this.backends.keys()].sort(); }
list(): string[] { return [this.agent.id]; }
}
+4 -4
View File
@@ -1,8 +1,8 @@
import type { AcpBackendConfig } from "../../config.js";
import type { AgentConfig } from "../../config.js";
import type { AcpBackendSpec } from "../types.js";
export function kimiBackend(config: AcpBackendConfig): AcpBackendSpec {
if (config.id !== "kimi") throw new Error(`Expected kimi backend, got '${config.id}'`);
if (!config.args.includes("acp")) throw new Error("Kimi ACP backend args must include 'acp'");
export function kimiBackend(config: AgentConfig): AcpBackendSpec {
if (config.id !== "kimi") throw new Error(`Expected kimi agent, got '${config.id}'`);
if (!config.args.includes("acp")) throw new Error("Kimi ACP agent args must include 'acp'");
return { ...config };
}
+32 -14
View File
@@ -2,11 +2,11 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { Readable, Writable } from "node:stream";
import * as acp from "@agentclientprotocol/sdk";
import type { AgentCapabilities, InitializeResponse, RequestPermissionRequest, RequestPermissionResponse, SessionNotification } from "@agentclientprotocol/sdk";
import type { RolePolicy } from "../config.js";
import type { PermissionPolicy } from "../config.js";
export interface AcpClientOptions {
initializeTimeoutMs: number;
policy: RolePolicy;
policy: PermissionPolicy;
}
export class AcpClient {
@@ -48,7 +48,12 @@ export class AcpClient {
this.collecting = false;
this.chunks = [];
if (this.capabilities.sessionCapabilities?.resume) {
await this.connection.agent.request(acp.methods.agent.session.resume, { sessionId, cwd, mcpServers: [] });
try {
await this.connection.agent.request(acp.methods.agent.session.resume, { sessionId, cwd, mcpServers: [] });
} catch (error) {
if (!this.capabilities.loadSession) throw error;
await this.connection.agent.request(acp.methods.agent.session.load, { sessionId, cwd, mcpServers: [] });
}
} else if (this.capabilities.loadSession) {
await this.connection.agent.request(acp.methods.agent.session.load, { sessionId, cwd, mcpServers: [] });
} else {
@@ -92,24 +97,37 @@ export class AcpClient {
}
}
export function decidePermission(request: RequestPermissionRequest, policy: RolePolicy): RequestPermissionResponse {
if (policy.permissionMode === "deny") return reject(request);
export function decidePermission(request: RequestPermissionRequest, policy: PermissionPolicy): RequestPermissionResponse {
if (policy.mode === "deny") return reject(request);
const allowOption = request.options.find((option) => option.kind === "allow_once") || request.options.find((option) => option.kind === "allow_always");
if (!allowOption) return reject(request);
if (policy.permissionMode === "auto") return { outcome: { outcome: "selected", optionId: allowOption.optionId } };
if (policy.mode === "auto") return { outcome: { outcome: "selected", optionId: allowOption.optionId } };
const name = String(request.toolCall.name || request.toolCall.kind || "").toLowerCase();
const title = String(request.toolCall.title || "").toLowerCase();
const allowedTool = policy.allowedTools.some((tool) => name === tool.toLowerCase() || title.startsWith(tool.toLowerCase()));
if (!allowedTool) return reject(request);
if (name === "bash" || name === "terminal" || title.startsWith("bash") || title.startsWith("terminal")) {
if (request.toolCall.rawInput === undefined || policy.allowedCommandPatterns.length === 0) return reject(request);
const input = typeof request.toolCall.rawInput === "string" ? request.toolCall.rawInput : JSON.stringify(request.toolCall.rawInput);
if (!policy.allowedCommandPatterns.some((pattern) => new RegExp(pattern).test(input))) return reject(request);
const name = typeof request.toolCall.name === "string" ? request.toolCall.name.trim().toLowerCase() : "";
if (!name || !policy.allowedTools.some((tool) => name === tool.trim().toLowerCase())) return reject(request);
if (name === "bash" || name === "terminal") {
if (policy.allowedCommandPatterns.length === 0) return reject(request);
const input = commandInput(request.toolCall.rawInput);
if (!input || !policy.allowedCommandPatterns.some((pattern) => fullMatch(pattern, input))) return reject(request);
}
return { outcome: { outcome: "selected", optionId: allowOption.optionId } };
}
function commandInput(rawInput: unknown): string | undefined {
if (typeof rawInput === "string") return rawInput;
if (typeof rawInput === "object" && rawInput !== null && !Array.isArray(rawInput)) {
const record = rawInput as Record<string, unknown>;
if (Object.keys(record).some((key) => !["command", "timeout", "timeoutMs"].includes(key))) return undefined;
return typeof record.command === "string" ? record.command : undefined;
}
return undefined;
}
function fullMatch(pattern: string, input: string): boolean {
const match = new RegExp(pattern).exec(input);
return match?.index === 0 && match[0] === input;
}
function reject(request: RequestPermissionRequest): RequestPermissionResponse {
const option = request.options.find((item) => item.kind === "reject_once") || request.options.find((item) => item.kind === "reject_always");
return option ? { outcome: { outcome: "selected", optionId: option.optionId } } : { outcome: { outcome: "cancelled" } };
+4 -4
View File
@@ -1,10 +1,10 @@
import { spawn } from "node:child_process";
import type { AcpBackendConfig } from "../config.js";
import type { AgentConfig } from "../config.js";
import type { InitializeResponse } from "@agentclientprotocol/sdk";
import { AcpClient } from "./client.js";
export async function probeAcpBackend(backend: AcpBackendConfig, timeoutMs = 10_000): Promise<InitializeResponse> {
const child = spawn(backend.command, backend.args, { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, ...backend.env } });
const client = new AcpClient(child, { initializeTimeoutMs: timeoutMs, policy: { permissionMode: "deny", allowedTools: [], allowedCommandPatterns: [] } });
export async function probeAcpBackend(agent: AgentConfig, timeoutMs = 10_000): Promise<InitializeResponse> {
const child = spawn(agent.command, agent.args, { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, ...agent.env } });
const client = new AcpClient(child, { initializeTimeoutMs: timeoutMs, policy: { mode: "deny", allowedTools: [], allowedCommandPatterns: [] } });
try { return await client.initialize(); } finally { client.close(); child.kill("SIGTERM"); }
}
+39 -41
View File
@@ -1,9 +1,8 @@
import type { AcpConfig } from "../config.js";
import { chatKeyFor, type DurableSessionStore, type SessionBinding } from "../core/durable-session-store.js";
import type { RoleRegistry } from "../roles/role-registry.js";
import type { AcpBackendRegistry } from "./backend-registry.js";
import type { ResolvedBot } from "../roles/role-registry.js";
import type { ConversationRequest, ConversationResponse, ConversationRuntime, RuntimeStats } from "./types.js";
import { AcpWorker } from "./worker.js";
import { AcpSessionRestoreError, AcpWorker } from "./worker.js";
export class AcpSessionManager implements ConversationRuntime {
private readonly workers = new Map<string, AcpWorker>();
@@ -14,8 +13,7 @@ export class AcpSessionManager implements ConversationRuntime {
constructor(
private readonly config: AcpConfig,
private readonly backends: AcpBackendRegistry,
private readonly roles: RoleRegistry,
private readonly bot: ResolvedBot,
private readonly store: DurableSessionStore
) {
this.sweeper = setInterval(() => void this.sweep(), config.sweepIntervalMs);
@@ -25,30 +23,36 @@ export class AcpSessionManager implements ConversationRuntime {
async prompt(request: ConversationRequest): Promise<ConversationResponse> {
if (this.shuttingDown) throw new Error("ACP runtime is shutting down");
const chatKey = chatKeyFor(request.platform, request.chatId);
const role = this.roles.get(this.store.getSelectedRole(chatKey, this.roles.defaultId()));
let binding = this.store.getBinding(chatKey, role.id);
if (binding && (binding.roleFingerprint !== role.fingerprint || binding.backendId !== role.backend || binding.workspace !== role.workspace)) {
let binding = this.store.getBinding(chatKey);
if (binding && (binding.botFingerprint !== this.bot.fingerprint || binding.agentId !== this.bot.agent.id || binding.workspace !== this.bot.workspace)) {
await this.dropBinding(binding);
binding = undefined;
}
const worker = await this.acquireWorker(role.id, binding);
let worker: AcpWorker;
try { worker = await this.acquireWorker(binding); }
catch (error) {
if (!(error instanceof AcpSessionRestoreError) || !binding) throw error;
await this.store.deleteBinding(chatKey);
binding = undefined;
worker = await this.acquireWorker();
}
this.inFlight.set(chatKey, worker);
try {
if (!binding) {
const now = Date.now();
await worker.prompt(role.bootstrap);
await worker.prompt(this.bot.bootstrap);
binding = {
chatKey, roleId: role.id, backendId: role.backend, nativeSessionId: worker.nativeSessionId!,
workspace: role.workspace, roleFingerprint: role.fingerprint, createdAt: now, updatedAt: now
chatKey, agentId: this.bot.agent.id, nativeSessionId: worker.nativeSessionId!,
workspace: this.bot.workspace, botFingerprint: this.bot.fingerprint, createdAt: now, updatedAt: now
};
await this.store.setBinding(binding);
}
const text = await worker.prompt(request.text);
await this.store.touchBinding(chatKey, role.id);
return { text: text || "(ACP agent returned no text)", roleId: role.id, backendId: role.backend };
await this.store.touchBinding(chatKey);
return { text: text || "(ACP agent returned no text)", botId: this.bot.id, agentId: this.bot.agent.id };
} catch (error) {
if (worker.nativeSessionId) this.workers.delete(workerKey(worker.backend.id, worker.nativeSessionId));
if (worker.nativeSessionId) this.workers.delete(workerKey(this.bot.agent.id, worker.nativeSessionId));
await worker.terminate();
throw error;
} finally {
@@ -64,25 +68,20 @@ export class AcpSessionManager implements ConversationRuntime {
async reset(platform: string, chatId: string): Promise<void> {
const chatKey = chatKeyFor(platform, chatId);
await this.cancel(platform, chatId);
const roleId = this.store.getSelectedRole(chatKey, this.roles.defaultId());
const binding = await this.store.deleteBinding(chatKey, roleId);
if (binding) await this.stopWorker(binding.backendId, binding.nativeSessionId);
}
async selectRole(platform: string, chatId: string, roleId: string): Promise<void> {
this.roles.get(roleId);
await this.store.setSelectedRole(chatKeyFor(platform, chatId), roleId);
}
selectedRole(platform: string, chatId: string): string {
return this.store.getSelectedRole(chatKeyFor(platform, chatId), this.roles.defaultId());
const binding = await this.store.deleteBinding(chatKey);
if (binding) await this.stopWorker(binding.agentId, binding.nativeSessionId);
}
status(platform: string, chatId: string): Record<string, string | number | boolean> {
const chatKey = chatKeyFor(platform, chatId);
const roleId = this.store.getSelectedRole(chatKey, this.roles.defaultId());
const binding = this.store.getBinding(chatKey, roleId);
return { role: roleId, backend: this.roles.get(roleId).backend, persisted: Boolean(binding), running: this.inFlight.has(chatKey) };
const binding = this.store.getBinding(chatKey);
return {
bot: this.bot.id,
agent: this.bot.agent.id,
workspace: this.bot.workspace,
persisted: Boolean(binding),
running: this.inFlight.has(chatKey)
};
}
stats(): RuntimeStats {
@@ -99,20 +98,19 @@ export class AcpSessionManager implements ConversationRuntime {
this.inFlight.clear();
}
private async acquireWorker(roleId: string, binding?: SessionBinding): Promise<AcpWorker> {
const role = this.roles.get(roleId);
private async acquireWorker(binding?: SessionBinding): Promise<AcpWorker> {
if (binding) {
const existing = this.workers.get(workerKey(binding.backendId, binding.nativeSessionId));
const existing = this.workers.get(workerKey(binding.agentId, binding.nativeSessionId));
if (existing) return existing;
}
await this.ensureCapacity();
const worker = new AcpWorker(this.backends.get(role.backend), role, this.config, (crashed, error) => {
const worker = new AcpWorker(this.bot, this.config, (crashed, error) => {
this.crashes++;
if (crashed.nativeSessionId) this.workers.delete(workerKey(crashed.backend.id, crashed.nativeSessionId));
if (crashed.nativeSessionId) this.workers.delete(workerKey(this.bot.agent.id, crashed.nativeSessionId));
console.error(`ACP worker crash: ${error.message}`);
});
const nativeSessionId = await worker.start(binding?.nativeSessionId);
this.workers.set(workerKey(role.backend, nativeSessionId), worker);
this.workers.set(workerKey(this.bot.agent.id, nativeSessionId), worker);
return worker;
}
@@ -134,12 +132,12 @@ export class AcpSessionManager implements ConversationRuntime {
}
private async dropBinding(binding: SessionBinding): Promise<void> {
await this.store.deleteBinding(binding.chatKey, binding.roleId);
await this.stopWorker(binding.backendId, binding.nativeSessionId);
await this.store.deleteBinding(binding.chatKey);
await this.stopWorker(binding.agentId, binding.nativeSessionId);
}
private async stopWorker(backendId: string, nativeSessionId: string): Promise<void> {
const key = workerKey(backendId, nativeSessionId);
private async stopWorker(agentId: string, nativeSessionId: string): Promise<void> {
const key = workerKey(agentId, nativeSessionId);
const worker = this.workers.get(key);
if (!worker) return;
this.workers.delete(key);
@@ -147,4 +145,4 @@ export class AcpSessionManager implements ConversationRuntime {
}
}
function workerKey(backendId: string, nativeSessionId: string): string { return `${backendId}:${nativeSessionId}`; }
function workerKey(agentId: string, nativeSessionId: string): string { return `${agentId}:${nativeSessionId}`; }
+4 -6
View File
@@ -1,4 +1,4 @@
import type { RolePolicy } from "../config.js";
import type { PermissionPolicy } from "../config.js";
export interface AcpBackendSpec {
id: string;
@@ -17,8 +17,8 @@ export interface ConversationRequest {
export interface ConversationResponse {
text: string;
roleId: string;
backendId: string;
botId: string;
agentId: string;
}
export interface RuntimeStats {
@@ -32,13 +32,11 @@ export interface ConversationRuntime {
prompt(request: ConversationRequest): Promise<ConversationResponse>;
cancel(platform: string, chatId: string): Promise<boolean>;
reset(platform: string, chatId: string): Promise<void>;
selectRole(platform: string, chatId: string, roleId: string): Promise<void>;
selectedRole(platform: string, chatId: string): string;
status(platform: string, chatId: string): Record<string, string | number | boolean>;
stats(): RuntimeStats;
shutdown(): Promise<void>;
}
export interface PermissionContext {
policy: RolePolicy;
policy: PermissionPolicy;
}
+12 -11
View File
@@ -1,8 +1,9 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { AcpConfig } from "../config.js";
import type { ResolvedRole } from "../roles/role-registry.js";
import type { ResolvedBot } from "../roles/role-registry.js";
import { AcpClient } from "./client.js";
import type { AcpBackendSpec } from "./types.js";
export class AcpSessionRestoreError extends Error {}
export class AcpWorker {
private child?: ChildProcessWithoutNullStreams;
@@ -16,16 +17,15 @@ export class AcpWorker {
inFlight = false;
constructor(
readonly backend: AcpBackendSpec,
readonly role: ResolvedRole,
readonly bot: ResolvedBot,
private readonly config: AcpConfig,
private readonly onCrash: (worker: AcpWorker, error: Error) => void
) {}
async start(nativeSessionId?: string): Promise<string> {
this.child = spawn(this.backend.command, this.backend.args, {
cwd: this.role.workspace,
env: { ...process.env, ...this.backend.env },
this.child = spawn(this.bot.agent.command, this.bot.agent.args, {
cwd: this.bot.workspace,
env: { ...process.env, ...this.bot.agent.env },
shell: false,
stdio: ["pipe", "pipe", "pipe"]
});
@@ -35,18 +35,19 @@ export class AcpWorker {
this.exited = true;
if (!this.stopping) this.crashed(new Error(`ACP worker exited (code=${code ?? "null"}, signal=${signal ?? "null"})`));
});
this.client = new AcpClient(this.child, { initializeTimeoutMs: this.config.initializeTimeoutMs, policy: this.role.policy });
this.client = new AcpClient(this.child, { initializeTimeoutMs: this.config.initializeTimeoutMs, policy: this.bot.permissions });
try {
await this.client.initialize();
if (nativeSessionId) {
await this.client.resumeSession(nativeSessionId, this.role.workspace);
await this.client.resumeSession(nativeSessionId, this.bot.workspace);
this.nativeSessionId = nativeSessionId;
} else {
this.nativeSessionId = await this.client.newSession(this.role.workspace);
this.nativeSessionId = await this.client.newSession(this.bot.workspace);
}
return this.nativeSessionId;
} catch (error) {
await this.terminate();
if (nativeSessionId) throw new AcpSessionRestoreError(`Cannot restore ACP session '${nativeSessionId}': ${error instanceof Error ? error.message : String(error)}`);
throw error;
}
}
@@ -109,7 +110,7 @@ export class AcpWorker {
if (!remaining) return;
const text = chunk.subarray(0, remaining).toString("utf8").trimEnd();
this.stderrBytes += Buffer.byteLength(text);
if (text) console.error(`[acp:${this.backend.id}] ${text}`);
if (text) console.error(`[acp:${this.bot.agent.id}] ${text}`);
}
private crashed(error: Error): void {
+30 -69
View File
@@ -1,85 +1,46 @@
#!/usr/bin/env node
import process from "node:process";
import { discoverBackends } from "./acp/discovery.js";
import { loadConfigFile } from "./cli/config-file.js";
import { runDoctor } from "./cli/doctor.js";
import { localBaseUrl } from "./cli/net.js";
import { printFeishu } from "./cli/print.js";
import { runSetup } from "./cli/setup.js";
import { defaultStateFile } from "./config.js";
import { startServer } from "./server.js";
import { runInstanceCommand } from "./cli/instance.js";
interface ParsedArgs { command?: string; rest: string[]; configPath?: string; json: boolean; help: boolean }
const COMMANDS = ["init", "setup", "start", "stop", "restart", "status", "logs", "doctor", "list"] as const;
type Command = typeof COMMANDS[number];
async function main(argv: string[]): Promise<number> {
const parsed = parseArgs(argv);
if (parsed.help || !parsed.command) { printHelp(); return 0; }
if (parsed.command === "setup") { await runSetup(parsed.configPath); return 0; }
if (parsed.command === "discover-backends" || parsed.command === "discover-agents") {
const backends = await discoverBackends();
if (parsed.json) console.log(JSON.stringify(backends, null, 2));
else for (const backend of backends) {
const version = backend.version ? ` (${backend.version})` : "";
const reason = backend.reason ? ` - ${backend.reason}` : "";
console.log(`${backend.id}\t${backend.status}\t${backend.command} ${backend.args.join(" ")}${version}${reason}`.trim());
}
if (argv.length === 0 || (argv.length === 1 && ["--help", "-h"].includes(argv[0]))) {
printHelp();
return 0;
}
if (parsed.command === "start") {
const loaded = loadConfigFile(parsed.configPath);
const running = await startServer(loaded.config);
return await new Promise<number>((resolve) => {
let stopping = false;
const stop = (signal: string): void => {
if (stopping) return;
stopping = true;
console.log(`Received ${signal}; shutting down...`);
void running.shutdown().then(() => resolve(0), (error) => { console.error(error); resolve(1); });
};
process.once("SIGINT", () => stop("SIGINT"));
process.once("SIGTERM", () => stop("SIGTERM"));
});
}
if (parsed.command === "status") { await printStatus(parsed.configPath); return 0; }
if (parsed.command === "doctor") { const loaded = loadConfigFile(parsed.configPath); return runDoctor(loaded.config, loaded.path); }
if (parsed.command === "print") {
const loaded = loadConfigFile(parsed.configPath);
if (parsed.rest[0] === "feishu") { await printFeishu(loaded.config); return 0; }
console.error(`Unknown print topic: ${parsed.rest[0] || "(missing)"}`); return 1;
}
console.error(`Unknown command: ${parsed.command}`); printHelp(); return 1;
}
function parseArgs(argv: string[]): ParsedArgs {
const rest: string[] = []; let command: string | undefined; let configPath: string | undefined; let json = false; let help = false;
for (let index = 0; index < argv.length; index++) {
const arg = argv[index];
if (arg === "--help" || arg === "-h") help = true;
else if (arg === "--json") json = true;
else if (arg === "--config") { configPath = argv[++index]; if (!configPath) throw new Error("--config requires a path"); }
else if (!command) command = arg; else rest.push(arg);
const [command, ...args] = argv;
const option = argv.find((arg) => arg.startsWith("-"));
if (option) throw new Error(`Unknown option: ${option}`);
if (!COMMANDS.includes(command as Command)) throw new Error(`Unknown command: ${command}`);
if (command === "list") {
if (args.length !== 0) throw new Error("list does not accept <bot-id>");
} else if (args.length !== 1) {
throw new Error(`${command} requires exactly one <bot-id>`);
}
return { command, rest, configPath, json, help };
}
async function printStatus(configPath?: string): Promise<void> {
const loaded = loadConfigFile(configPath); const baseUrl = localBaseUrl(loaded.config);
console.log(`Config: ${loaded.path}${loaded.exists ? "" : " (seeded from config.example.json)"}`);
console.log(`Server: ${loaded.config.server.host}:${loaded.config.server.port}`);
console.log(`Default role: ${loaded.config.defaultRole}`);
console.log(`Roles: ${loaded.config.roles.map(({ id }) => id).join(", ")}`);
console.log(`Backends: ${loaded.config.backends.map(({ id }) => id).join(", ")}`);
console.log(`State file: ${defaultStateFile(loaded.config)}`);
try {
const response = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(1_000) });
console.log(`Health probe: HTTP ${response.status} ${await response.text()}`);
} catch { console.log("Health probe: not reachable on local URL"); }
return runInstanceCommand(command, args);
}
function printHelp(): void {
console.log(`gori-agent - multi-IM ACP agent gateway\n\nUsage:\n gori-agent setup [--config path]\n gori-agent discover-backends [--json]\n gori-agent start [--config path]\n gori-agent status [--config path]\n gori-agent doctor [--config path]\n gori-agent print feishu [--config path]\n\nDeprecated alias: discover-agents`);
console.log(`gori-agent - single-Bot ACP gateway
Usage:
gori-agent init <bot-id>
gori-agent setup <bot-id>
gori-agent start <bot-id>
gori-agent stop <bot-id>
gori-agent restart <bot-id>
gori-agent status <bot-id>
gori-agent logs <bot-id>
gori-agent doctor <bot-id>
gori-agent list
Instances root: \${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances`);
}
main(process.argv.slice(2)).then((code) => { if (Number.isInteger(code)) process.exitCode = code; }).catch((error) => {
console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1;
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
+98 -36
View File
@@ -7,60 +7,122 @@ import { loadConfigFromPath, parseConfig } from "../config.js";
export interface LoadedConfigFile {
path: string;
config: AppConfig;
exists: boolean;
source: "explicit" | "env" | "local" | "example";
source: "explicit" | "env" | "local";
}
export function projectRoot(): string {
return path.resolve(new URL("../..", import.meta.url).pathname);
}
export function resolveCliConfigPath(configPath?: string): { path: string; exists: boolean; source: LoadedConfigFile["source"] } {
if (configPath) {
const resolved = path.resolve(configPath);
return { path: resolved, exists: fs.existsSync(resolved), source: "explicit" };
}
if (process.env.GORI_GATEWAY_CONFIG) {
const resolved = path.resolve(process.env.GORI_GATEWAY_CONFIG);
return { path: resolved, exists: fs.existsSync(resolved), source: "env" };
}
const local = path.resolve("config.json");
if (fs.existsSync(local)) return { path: local, exists: true, source: "local" };
return { path: path.resolve("config.json"), exists: false, source: "example" };
export function resolveCliConfigPath(configPath?: string): { path: string; source: LoadedConfigFile["source"] } {
if (configPath) return { path: path.resolve(configPath), source: "explicit" };
if (process.env.GORI_GATEWAY_CONFIG) return { path: path.resolve(process.env.GORI_GATEWAY_CONFIG), source: "env" };
return { path: path.resolve("config.json"), source: "local" };
}
export function loadConfigFile(configPath?: string): LoadedConfigFile {
const resolved = resolveCliConfigPath(configPath);
if (resolved.exists) {
return {
path: resolved.path,
config: loadConfigFromPath(resolved.path),
exists: true,
source: resolved.source
};
if (!fs.existsSync(resolved.path)) {
throw new Error(`Runtime config does not exist: ${resolved.path}; config.example.json is documentation only`);
}
return { path: resolved.path, config: loadConfigFromPath(resolved.path), source: resolved.source };
}
export function loadExampleConfig(): AppConfig {
const examplePath = path.join(projectRoot(), "config.example.json");
const raw = fs.readFileSync(examplePath, "utf8");
const config = parseConfig(JSON.parse(raw) as unknown);
return {
path: resolved.path,
config,
exists: false,
source: "example"
};
return parseConfig(JSON.parse(fs.readFileSync(examplePath, "utf8")) as unknown);
}
export function writeConfigFile(configPath: string, config: AppConfig): void {
const parsed = parseConfig(config);
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
const directory = path.dirname(configPath);
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
const temp = path.join(directory, `.${path.basename(configPath)}.${process.pid}.${Date.now()}.tmp`);
let fd: number | undefined;
try {
fd = fs.openSync(temp, "wx", 0o600);
fs.writeFileSync(fd, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
fs.fsyncSync(fd);
fs.closeSync(fd);
fd = undefined;
fs.renameSync(temp, configPath);
fs.chmodSync(configPath, 0o600);
const dirFd = fs.openSync(directory, "r");
try { fs.fsyncSync(dirFd); } finally { fs.closeSync(dirFd); }
} catch (error) {
if (fd !== undefined) try { fs.closeSync(fd); } catch { /* ignore cleanup failure */ }
try { fs.unlinkSync(temp); } catch { /* ignore cleanup failure */ }
throw error;
}
}
export function operationalConfigProblems(config: AppConfig): string[] {
const problems: string[] = [];
if (config.bot.id === "BOT_ID") problems.push("bot.id is still the example placeholder");
if (isPlaceholder(config.bot.workspace) || config.bot.workspace.includes("/absolute/path/")) problems.push("bot.workspace is still a placeholder");
if (isPlaceholder(config.bot.agent.command) || config.bot.agent.command.includes("/home/USER/")) problems.push("bot.agent.command is still a placeholder");
else if (!isExecutable(config.bot.agent.command)) problems.push("bot.agent.command is not executable");
try {
if (!fs.statSync(config.bot.workspace).isDirectory()) problems.push("bot.workspace must be an existing directory");
} catch { problems.push("bot.workspace must be an existing directory"); }
const platform = config.gateway.platform;
switch (platform.type) {
case "qq":
if (missingOrPlaceholder(platform.appId)) problems.push("QQ appId is missing or placeholder");
if (missingOrPlaceholder(platform.clientSecret)) problems.push("QQ clientSecret is missing or placeholder");
if (platform.botNames.some(isPlaceholder)) problems.push("QQ botNames contains a placeholder");
break;
case "feishu":
if (missingOrPlaceholder(platform.appId)) problems.push("Feishu appId is missing or placeholder");
if (missingOrPlaceholder(platform.appSecret)) problems.push("Feishu appSecret is missing or placeholder");
break;
case "wecom":
if (missingOrPlaceholder(platform.corpId)) problems.push("WeCom corpId is missing or placeholder");
if (missingOrPlaceholder(platform.agentId)) problems.push("WeCom agentId is missing or placeholder");
if (missingOrPlaceholder(platform.secret)) problems.push("WeCom secret is missing or placeholder");
break;
case "webhook":
if (missingOrPlaceholder(platform.secret)) problems.push("Generic webhook secret is missing or placeholder");
break;
case "weixin":
if (missingOrPlaceholder(platform.secret)) problems.push("Weixin bridge secret is missing or placeholder");
break;
}
return problems;
}
export function assertOperationalConfig(config: AppConfig, configFile?: string): void {
const problems = operationalConfigProblems(config);
if (configFile) {
const stat = fs.lstatSync(configFile);
if (!stat.isFile() || stat.isSymbolicLink()) problems.push("config must be a regular file");
const mode = stat.mode & 0o777;
if (mode !== 0o600) problems.push(`config file mode must be 0600, got 0${mode.toString(8)}`);
}
if (problems.length > 0) throw new Error(`Refusing to start: ${problems.join("; ")}`);
}
function missingOrPlaceholder(value: string): boolean { return !value || isPlaceholder(value); }
function isExecutable(command: string): boolean {
try {
if (command.includes(path.sep)) {
fs.accessSync(command, fs.constants.X_OK);
return fs.statSync(command).isFile();
}
return (process.env.PATH || "").split(path.delimiter).some((directory) => {
try { fs.accessSync(path.join(directory, command), fs.constants.X_OK); return fs.statSync(path.join(directory, command)).isFile(); }
catch { return false; }
});
} catch { return false; }
}
function isPlaceholder(value: string): boolean {
return /^(?:BOT_ID|QQ_(?:APP_ID|CLIENT_SECRET|BOT_NAME)|(?:FEISHU|WECOM|WEBHOOK|WEIXIN)_[A-Z0-9_]+)$/.test(value)
|| value.includes("<") || value.includes(">");
}
export function readConfigJson(configPath?: string): unknown {
const loaded = loadConfigFile(configPath);
return loaded.config;
return loadConfigFile(configPath).config;
}
+27 -33
View File
@@ -2,61 +2,55 @@ import fs from "node:fs";
import path from "node:path";
import { probeAcpBackend } from "../acp/probe.js";
import { defaultStateFile, type AppConfig } from "../config.js";
import { RoleRegistry } from "../roles/role-registry.js";
import { BotProfileResolver } from "../roles/role-registry.js";
import { operationalConfigProblems } from "./config-file.js";
import { printUrlHints } from "./net.js";
export async function runDoctor(config: AppConfig, configPath: string): Promise<number> {
let problems = 0;
console.log(`Config: ${configPath}`);
console.log(`Server: ${config.server.host}:${config.server.port}`);
console.log(`Config version: ${config.configVersion}`);
console.log(`Bot: ${config.bot.id}`);
console.log(`Server: ${config.gateway.server.host}:${config.gateway.server.port}`);
console.log(`Platform: ${config.gateway.platform.type}`);
for (const problem of operationalConfigProblems(config)) problems += reportError(problem);
try {
new RoleRegistry(config);
console.log(`Default role: ${config.defaultRole}`);
const mode = fs.statSync(configPath).mode & 0o777;
if (mode !== 0o600) problems += reportError(`config file mode must be 0600, got 0${mode.toString(8)}`);
} catch (error) {
problems += reportError(`cannot stat config file: ${error instanceof Error ? error.message : String(error)}`);
}
try { new BotProfileResolver(config); } catch (error) {
problems += reportError(error instanceof Error ? error.message : String(error));
}
for (const role of config.roles) {
if (!fs.existsSync(role.workspace)) problems += reportError(`role '${role.id}' workspace does not exist: ${role.workspace}`);
if (role.policy.permissionMode === "auto") console.log(`WARN: role '${role.id}' auto-approves every permission request.`);
if (role.policy.permissionMode === "allowlist" && role.policy.allowedTools.includes("bash") && role.policy.allowedCommandPatterns.length === 0) {
console.log(`WARN: role '${role.id}' allows bash by name but has no command patterns; bash requests will be denied.`);
}
if (config.gateway.platform.type === "qq") console.log(`QQ connection mode: ${config.gateway.platform.connectionMode}`);
if (config.bot.permissions.mode === "auto") console.log("WARN: bot auto-approves every permission request.");
if (config.bot.permissions.mode === "allowlist" && config.bot.permissions.allowedTools.some((tool) => ["bash", "terminal"].includes(tool.toLowerCase())) && config.bot.permissions.allowedCommandPatterns.length === 0) {
console.log("WARN: bot allows bash/terminal by name but has no command patterns; command requests will be denied.");
}
for (const backend of config.backends) {
try {
const initialized = await probeAcpBackend(backend, config.acp.initializeTimeoutMs);
const capabilities = initialized.agentCapabilities;
console.log(`ACP '${backend.id}': ready (${initialized.agentInfo?.name || "unknown"} ${initialized.agentInfo?.version || ""})`);
console.log(` load=${Boolean(capabilities?.loadSession)} resume=${Boolean(capabilities?.sessionCapabilities?.resume)} list=${Boolean(capabilities?.sessionCapabilities?.list)} close=${Boolean(capabilities?.sessionCapabilities?.close)}`);
if (!capabilities?.loadSession && !capabilities?.sessionCapabilities?.resume) problems += reportError(`backend '${backend.id}' cannot restore sessions`);
console.log(" model config: not advertised by initialize; using the Kimi default model");
} catch (error) {
problems += reportError(`ACP '${backend.id}' initialize failed: ${error instanceof Error ? error.message : String(error)}`);
}
try {
const initialized = await probeAcpBackend(config.bot.agent, config.runtime.acp.initializeTimeoutMs);
const capabilities = initialized.agentCapabilities;
console.log(`ACP '${config.bot.agent.id}': ready (${initialized.agentInfo?.name || "unknown"} ${initialized.agentInfo?.version || ""})`);
console.log(` load=${Boolean(capabilities?.loadSession)} resume=${Boolean(capabilities?.sessionCapabilities?.resume)} list=${Boolean(capabilities?.sessionCapabilities?.list)} close=${Boolean(capabilities?.sessionCapabilities?.close)}`);
if (!capabilities?.loadSession && !capabilities?.sessionCapabilities?.resume) problems += reportError(`agent '${config.bot.agent.id}' cannot restore sessions`);
} catch (error) {
problems += reportError(`ACP '${config.bot.agent.id}' initialize failed: ${error instanceof Error ? error.message : String(error)}`);
}
const stateFile = defaultStateFile(config);
try {
const stateDirectory = path.dirname(stateFile);
fs.mkdirSync(stateDirectory, { recursive: true });
fs.mkdirSync(stateDirectory, { recursive: true, mode: 0o700 });
fs.accessSync(stateDirectory, fs.constants.R_OK | fs.constants.W_OK);
console.log(`State file: ${stateFile}`);
} catch (error) {
problems += reportError(`state directory is unavailable: ${error instanceof Error ? error.message : String(error)}`);
}
const enabledPlatforms = Object.entries(config.platforms).filter(([, value]) => value.enabled).map(([name]) => name);
console.log(`Enabled platforms: ${enabledPlatforms.join(", ") || "none"}`);
if (config.platforms.qq.enabled) {
console.log(`QQ connection mode: ${config.platforms.qq.connectionMode}`);
if (!config.platforms.qq.appId) problems += reportError("QQ appId is empty.");
if (!config.platforms.qq.clientSecret || config.platforms.qq.clientSecret === "replace-me") console.log("WARN: QQ clientSecret is missing or placeholder.");
if (config.platforms.qq.connectionMode === "webhook" && config.platforms.qq.verifySignature && !(config.platforms.qq.botSecret || config.platforms.qq.clientSecret)) {
problems += reportError("QQ botSecret or clientSecret is required for webhook signature verification.");
}
}
await printUrlHints(config);
return problems > 0 ? 1 : 0;
}
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
import process from "node:process";
import { pathToFileURL } from "node:url";
import { assertOperationalConfig, loadConfigFile } from "./config-file.js";
import { startServer } from "../server.js";
interface ShutdownTarget { shutdown(): Promise<void> }
interface SignalEmitter {
once(signal: "SIGINT" | "SIGTERM", listener: () => void): unknown;
removeListener(signal: "SIGINT" | "SIGTERM", listener: () => void): unknown;
}
export async function runInstanceRunner(argv: string[]): Promise<number> {
if (argv.length !== 1) throw new Error("instance-runner requires exactly one config path");
const loaded = loadConfigFile(argv[0]);
assertOperationalConfig(loaded.config, loaded.path);
return waitForShutdown(await startServer(loaded.config));
}
export function waitForShutdown(running: ShutdownTarget, signals: SignalEmitter = process): Promise<number> {
return new Promise<number>((resolve) => {
let stopping = false;
const finish = (code: number): void => {
signals.removeListener("SIGINT", onSigint);
signals.removeListener("SIGTERM", onSigterm);
resolve(code);
};
const stop = (signal: string): void => {
if (stopping) return;
stopping = true;
console.log(`Received ${signal}; shutting down...`);
void running.shutdown().then(() => finish(0), (error) => {
console.error(error);
finish(1);
});
};
const onSigint = (): void => stop("SIGINT");
const onSigterm = (): void => stop("SIGTERM");
signals.once("SIGINT", onSigint);
signals.once("SIGTERM", onSigterm);
});
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
runInstanceRunner(process.argv.slice(2)).then((code) => { process.exitCode = code; }).catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}
+389
View File
@@ -0,0 +1,389 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { defaultStateFile } from "../config.js";
import { assertOperationalConfig, loadConfigFile } from "./config-file.js";
import { runDoctor } from "./doctor.js";
import { localBaseUrl } from "./net.js";
import { runSetup } from "./setup.js";
const BOT_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62})$/;
export function agentRoot(): string {
return path.resolve(process.env.GORI_AGENT_ROOT || path.join(os.homedir(), ".gori-agent"));
}
export function instancesRoot(): string { return path.join(agentRoot(), "instances"); }
export function instanceDirectory(botId: string): string {
validateBotId(botId);
return path.join(instancesRoot(), botId);
}
export function instanceConfigPath(botId: string): string { return path.join(instanceDirectory(botId), "config.json"); }
export async function runInstanceCommand(action: string | undefined, args: string[]): Promise<number> {
if (action === "list") {
if (args.length !== 0) throw new Error("list does not accept <bot-id>");
return listInstances();
}
if (!action || !["init", "setup", "start", "stop", "restart", "status", "logs", "doctor"].includes(action)) {
throw new Error(`Unknown command: ${action || "(missing)"}`);
}
if (args.length !== 1) throw new Error(`${action} requires exactly one <bot-id>`);
const botId = args[0];
validateBotId(botId);
switch (action) {
case "init": return initInstance(botId);
case "setup": return setupInstance(botId);
case "start": return startInstance(botId);
case "stop": return stopInstance(botId);
case "restart": await stopInstance(botId); return startInstance(botId);
case "status": return statusInstance(botId);
case "logs": return logsInstance(botId);
case "doctor": return doctorInstance(botId);
default: throw new Error(`Unknown command: ${action}`);
}
}
async function initInstance(botId: string): Promise<number> {
const directory = instanceDirectory(botId);
if (fs.existsSync(directory)) throw new Error(`Instance already exists: ${botId}`);
ensureInstanceDirectories(directory, true);
try {
await runSetup(path.join(directory, "config.json"), { botId, requireNew: true, writeWithoutConfirmation: true });
return 0;
} catch (error) {
cleanupFailedInit(directory);
throw error;
}
}
async function setupInstance(botId: string): Promise<number> {
const loaded = loadInstance(botId);
assertConfigMode(loaded.path);
const paths = runtimePaths(botId);
ensureInstanceDirectories(paths.home, false);
const current = inspectPid(paths.pidFile, loaded.path);
assertSetupPidSafe(botId, current);
if (current.kind === "stale") removeStalePid(paths.pidFile, current);
const written = await runSetup(loaded.path, { botId, instancesDirectory: instancesRoot() });
if (!written) return 0;
return doctorInstance(botId);
}
async function startInstance(botId: string): Promise<number> {
const loaded = loadInstance(botId);
assertOperationalConfig(loaded.config, loaded.path);
const paths = runtimePaths(botId);
ensureInstanceDirectories(paths.home, false);
const current = inspectPid(paths.pidFile, loaded.path);
if (current.kind === "running") throw new Error(`Instance '${botId}' is already running (pid ${current.pid})`);
if (current.kind === "foreign") throw new Error(`Refusing to start: PID file references unrelated live process ${current.pid}`);
if (current.kind === "stale") removeStalePid(paths.pidFile, current);
await assertPortAvailable(loaded.config.gateway.server.host, loaded.config.gateway.server.port);
const logFd = fs.openSync(paths.logFile, "a", 0o600);
fs.chmodSync(paths.logFile, 0o600);
const runnerFile = instanceRunnerPath();
let child: ReturnType<typeof spawn> | undefined;
try {
child = spawn(process.execPath, [runnerFile, loaded.path], {
cwd: paths.home,
env: { ...process.env, GORI_AGENT_HOME: paths.home },
detached: true,
stdio: ["ignore", logFd, logFd]
});
await new Promise<void>((resolve, reject) => {
child!.once("spawn", resolve);
child!.once("error", reject);
});
if (!child.pid) throw new Error("Instance child process has no PID");
atomicWriteMode(paths.pidFile, `${child.pid}\n`, 0o600);
await waitForHealthyInstance(child.pid, loaded.config.gateway.platform.type, botId, loaded.config);
child.unref();
console.log(`Instance '${botId}' started (pid ${child.pid})`);
console.log(`Log: ${paths.logFile}`);
return 0;
} catch (error) {
if (child?.pid) {
await terminateStartedChild(child.pid);
removePidIfOwned(paths.pidFile, child.pid);
}
throw new Error(`Instance '${botId}' failed to start; inspect ${paths.logFile}: ${error instanceof Error ? error.message : String(error)}`);
} finally { fs.closeSync(logFd); }
}
async function stopInstance(botId: string): Promise<number> {
const loaded = loadInstance(botId);
const paths = runtimePaths(botId);
const current = inspectPid(paths.pidFile, loaded.path);
if (current.kind === "foreign") throw new Error(`Refusing to stop: PID file references unrelated live process ${current.pid}`);
if (current.kind !== "running") {
if (current.kind === "stale") removeStalePid(paths.pidFile, current);
console.log(`Instance '${botId}' is not running`);
return 0;
}
process.kill(current.pid, "SIGTERM");
for (let attempt = 0; attempt < 100 && isPidRunning(current.pid); attempt++) await delay(100);
if (isPidRunning(current.pid)) throw new Error(`Instance '${botId}' did not stop within 10 seconds (pid ${current.pid})`);
removeStalePid(paths.pidFile);
console.log(`Instance '${botId}' stopped (pid ${current.pid})`);
return 0;
}
async function statusInstance(botId: string): Promise<number> {
const loaded = loadInstance(botId);
const paths = runtimePaths(botId);
const current = inspectPid(paths.pidFile, loaded.path);
const pid = current.kind === "running" ? current.pid : undefined;
console.log(`Instance: ${botId}`);
console.log(`Process: ${pid ? `running (pid ${pid})` : current.kind === "foreign" ? `PID identity mismatch (${current.pid})` : "not running"}`);
console.log(`Config: ${loaded.path}`);
console.log(`Platform: ${loaded.config.gateway.platform.type}`);
console.log(`Agent: ${loaded.config.bot.agent.id}`);
console.log(`Workspace: ${loaded.config.bot.workspace}`);
console.log(`State file: ${withInstanceHome(paths.home, () => defaultStateFile(loaded.config))}`);
if (current.kind === "foreign") return 1;
if (pid) {
try {
const response = await fetch(`${localBaseUrl(loaded.config)}/health`, { signal: AbortSignal.timeout(1_000) });
const health = await response.json() as { botId?: string; platform?: string };
const matches = response.ok && health.botId === botId && health.platform === loaded.config.gateway.platform.type;
console.log(`Health: HTTP ${response.status}, identity ${matches ? "verified" : "mismatch"}`);
if (!matches) return 1;
} catch { console.log("Health: not reachable on local URL"); return 1; }
}
return 0;
}
async function logsInstance(botId: string): Promise<number> {
loadInstance(botId);
const paths = runtimePaths(botId);
ensurePrivateDirectory(path.dirname(paths.logFile), true);
const fd = fs.openSync(paths.logFile, "a", 0o600);
fs.closeSync(fd);
fs.chmodSync(paths.logFile, 0o600);
return new Promise<number>((resolve, reject) => {
const child = spawn("tail", ["-f", paths.logFile], { stdio: "inherit" });
child.once("error", reject);
child.once("close", (code) => resolve(code || 0));
});
}
async function doctorInstance(botId: string): Promise<number> {
const loaded = loadInstance(botId);
return withInstanceHomeAsync(instanceDirectory(botId), () => runDoctor(loaded.config, loaded.path));
}
function listInstances(): number {
const root = instancesRoot();
if (!fs.existsSync(root)) return 0;
ensurePrivateDirectory(root, false);
for (const entry of fs.readdirSync(root, { withFileTypes: true }).filter((item) => item.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
if (!BOT_ID_PATTERN.test(entry.name)) continue;
const configFile = path.join(root, entry.name, "config.json");
let state = "missing-config";
if (fs.existsSync(configFile)) {
const current = inspectPid(path.join(root, entry.name, "state", "gori-agent.pid"), configFile);
state = current.kind === "running" ? "running" : current.kind === "foreign" ? "pid-mismatch" : "stopped";
}
console.log(`${entry.name}\t${state}`);
}
return 0;
}
function loadInstance(botId: string): ReturnType<typeof loadConfigFile> {
const configFile = instanceConfigPath(botId);
if (fs.existsSync(path.dirname(configFile))) ensurePrivateDirectory(path.dirname(configFile), false);
if (fs.existsSync(configFile)) assertConfigMode(configFile);
const loaded = loadConfigFile(configFile);
if (loaded.config.bot.id !== botId) throw new Error(`Instance directory '${botId}' does not match config bot.id '${loaded.config.bot.id}'`);
return loaded;
}
function runtimePaths(botId: string): { home: string; pidFile: string; logFile: string } {
const home = instanceDirectory(botId);
return { home, pidFile: path.join(home, "state", "gori-agent.pid"), logFile: path.join(home, "logs", "gori-agent.log") };
}
function ensureInstanceDirectories(home: string, createHome: boolean): void {
ensurePrivateDirectory(agentRoot(), createHome);
ensurePrivateDirectory(instancesRoot(), createHome);
ensurePrivateDirectory(home, createHome);
ensurePrivateDirectory(path.join(home, "state"), true);
ensurePrivateDirectory(path.join(home, "logs"), true);
}
function ensurePrivateDirectory(directory: string, create: boolean): void {
if (!fs.existsSync(directory)) {
if (!create) throw new Error(`Required directory does not exist: ${directory}`);
fs.mkdirSync(directory, { mode: 0o700 });
}
const stat = fs.lstatSync(directory);
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Refusing unsafe instance path: ${directory}`);
const mode = stat.mode & 0o777;
if (mode !== 0o700) throw new Error(`Directory mode must be 0700: ${directory} has 0${mode.toString(8)}`);
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) throw new Error(`Directory is not owned by the current user: ${directory}`);
}
function cleanupFailedInit(directory: string): void {
if (fs.existsSync(path.join(directory, "config.json"))) return;
for (const name of ["state", "logs"]) {
try { fs.rmdirSync(path.join(directory, name)); } catch { /* preserve non-empty or absent directories */ }
}
try { fs.rmdirSync(directory); } catch { /* preserve non-empty directory */ }
}
function validateBotId(botId: string): void {
if (!BOT_ID_PATTERN.test(botId)) throw new Error(`Invalid bot ID '${botId}'; use lowercase letters, digits, and hyphens`);
}
function assertConfigMode(configFile: string): void {
const stat = fs.lstatSync(configFile);
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Config must be a regular file");
const mode = stat.mode & 0o777;
if (mode !== 0o600) throw new Error(`Config file mode must be 0600, got 0${mode.toString(8)}`);
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) throw new Error("Config file is not owned by the current user");
}
export function instanceRunnerPath(): string {
const moduleFile = fileURLToPath(import.meta.url);
const projectRoot = path.resolve(path.dirname(moduleFile), "../..");
return path.join(projectRoot, "dist", "cli", "instance-runner.js");
}
export function matchesInstanceRunner(commandLine: string[], executable: string, configFile: string): boolean {
if (commandLine.length !== 3) return false;
try {
return fs.realpathSync(executable) === fs.realpathSync(process.execPath)
&& path.resolve(commandLine[1]) === instanceRunnerPath()
&& path.resolve(commandLine[2]) === path.resolve(configFile);
} catch { return false; }
}
type PidInspection = { kind: "absent" } | { kind: "stale"; dev?: number; ino?: number } | { kind: "running" | "foreign"; pid: number };
export function assertSetupPidSafe(botId: string, current: PidInspection): void {
if (current.kind === "running") throw new Error(`Refusing to setup: instance '${botId}' is running (pid ${current.pid})`);
if (current.kind === "foreign") throw new Error(`Refusing to setup: PID file references unrelated live process ${current.pid}`);
}
function inspectPid(pidFile: string, configFile: string): PidInspection {
let text: string;
let stat: fs.Stats;
try {
stat = fs.lstatSync(pidFile);
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`PID file must be a regular file: ${pidFile}`);
const mode = stat.mode & 0o777;
if (mode !== 0o600) throw new Error(`PID file mode must be 0600: ${pidFile} has 0${mode.toString(8)}`);
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) throw new Error(`PID file is not owned by the current user: ${pidFile}`);
text = fs.readFileSync(pidFile, "utf8").trim();
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { kind: "absent" };
throw error;
}
const pid = Number(text);
if (!Number.isSafeInteger(pid) || pid <= 1 || !isPidRunning(pid)) return { kind: "stale", dev: stat.dev, ino: stat.ino };
try {
const commandLine = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").filter(Boolean);
const executable = fs.readlinkSync(`/proc/${pid}/exe`);
return { kind: matchesInstanceRunner(commandLine, executable, configFile) ? "running" : "foreign", pid };
} catch { return isPidRunning(pid) ? { kind: "foreign", pid } : { kind: "stale", dev: stat.dev, ino: stat.ino }; }
}
function isPidRunning(pid: number | undefined): boolean {
if (!pid) return false;
try { process.kill(pid, 0); return true; }
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
}
function removeStalePid(pidFile: string, inspection?: Extract<PidInspection, { kind: "stale" }>): void {
try {
if (inspection?.dev !== undefined && inspection.ino !== undefined) {
const current = fs.lstatSync(pidFile);
if (current.dev !== inspection.dev || current.ino !== inspection.ino) return;
}
fs.unlinkSync(pidFile);
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
}
function removePidIfOwned(pidFile: string, pid: number): void {
try {
if (fs.readFileSync(pidFile, "utf8").trim() === String(pid)) fs.unlinkSync(pidFile);
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
}
function atomicWriteMode(file: string, content: string, mode: number): void {
const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
let fd: number | undefined;
try {
fd = fs.openSync(temp, "wx", mode);
fs.writeFileSync(fd, content, "utf8");
fs.fsyncSync(fd);
fs.closeSync(fd);
fd = undefined;
fs.linkSync(temp, file);
fs.unlinkSync(temp);
fs.chmodSync(file, mode);
const dirFd = fs.openSync(path.dirname(file), "r");
try { fs.fsyncSync(dirFd); } finally { fs.closeSync(dirFd); }
} catch (error) {
if (fd !== undefined) try { fs.closeSync(fd); } catch { /* ignore cleanup failure */ }
try { fs.unlinkSync(temp); } catch { /* ignore cleanup failure */ }
throw error;
}
}
function assertPortAvailable(host: string, port: number): Promise<void> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", (error: NodeJS.ErrnoException) => reject(new Error(`Cannot start: ${host}:${port} is unavailable (${error.code || error.message})`)));
server.listen(port, host, () => server.close((error) => error ? reject(error) : resolve()));
});
}
async function waitForHealthyInstance(pid: number, platform: string, botId: string, config: Parameters<typeof localBaseUrl>[0]): Promise<void> {
let lastError = "health endpoint not ready";
for (let attempt = 0; attempt < 30; attempt++) {
if (!isPidRunning(pid)) throw new Error("child process exited before becoming healthy");
try {
const response = await fetch(`${localBaseUrl(config)}/health`, { signal: AbortSignal.timeout(500) });
const health = await response.json() as { botId?: string; platform?: string };
if (response.ok && health.botId === botId && health.platform === platform) return;
lastError = `health identity mismatch (HTTP ${response.status})`;
} catch (error) { lastError = error instanceof Error ? error.message : String(error); }
await delay(100);
}
throw new Error(`health check timed out: ${lastError}`);
}
function delay(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); }
async function terminateStartedChild(pid: number): Promise<void> {
if (!isPidRunning(pid)) return;
try { process.kill(pid, "SIGTERM"); } catch { return; }
for (let attempt = 0; attempt < 20 && isPidRunning(pid); attempt++) await delay(50);
if (isPidRunning(pid)) try { process.kill(pid, "SIGKILL"); } catch { /* process already exited */ }
}
function withInstanceHome<T>(home: string, fn: () => T): T {
const previous = process.env.GORI_AGENT_HOME;
process.env.GORI_AGENT_HOME = home;
try { return fn(); } finally { restoreInstanceHome(previous); }
}
async function withInstanceHomeAsync<T>(home: string, fn: () => Promise<T>): Promise<T> {
const previous = process.env.GORI_AGENT_HOME;
process.env.GORI_AGENT_HOME = home;
try { return await fn(); } finally { restoreInstanceHome(previous); }
}
function restoreInstanceHome(previous: string | undefined): void {
if (previous === undefined) delete process.env.GORI_AGENT_HOME;
else process.env.GORI_AGENT_HOME = previous;
}
+10 -17
View File
@@ -2,48 +2,41 @@ import os from "node:os";
import type { AppConfig } from "../config.js";
export function localBaseUrl(config: AppConfig): string {
return `http://localhost:${config.server.port}`;
const configured = config.gateway.server.host;
const host = configured === "0.0.0.0" ? "127.0.0.1" : configured === "::" ? "[::1]" : configured.includes(":") && !configured.startsWith("[") ? `[${configured}]` : configured;
return `http://${host}:${config.gateway.server.port}`;
}
export function lanBaseUrls(config: AppConfig): string[] {
const urls: string[] = [];
for (const interfaces of Object.values(os.networkInterfaces())) {
for (const item of interfaces || []) {
if (item.family === "IPv4" && !item.internal) {
urls.push(`http://${item.address}:${config.server.port}`);
}
if (item.family === "IPv4" && !item.internal) urls.push(`http://${item.address}:${config.gateway.server.port}`);
}
}
return urls;
}
export async function publicBaseUrlHint(config: AppConfig): Promise<string | undefined> {
if (config.server.publicBaseUrl) return config.server.publicBaseUrl.replace(/\/$/, "");
if (config.gateway.server.publicBaseUrl) return config.gateway.server.publicBaseUrl.replace(/\/$/, "");
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 1_500);
try {
const response = await fetch("https://api.ipify.org?format=text", { signal: controller.signal });
if (!response.ok) return undefined;
const ip = (await response.text()).trim();
if (!ip) return undefined;
return `http://${ip}:${config.server.port}`;
} catch {
return undefined;
} finally {
clearTimeout(timer);
}
return ip ? `http://${ip}:${config.gateway.server.port}` : undefined;
} catch { return undefined; } finally { clearTimeout(timer); }
}
export async function printUrlHints(config: AppConfig): Promise<void> {
console.log(`Local: ${localBaseUrl(config)}`);
const lanUrls = lanBaseUrls(config);
if (lanUrls.length > 0) console.log(`LAN: ${lanUrls.join(", ")}`);
const publicHint = await publicBaseUrlHint(config);
if (publicHint) console.log(`Public hint: ${publicHint}`);
if (!config.server.publicBaseUrl) console.log("Set server.publicBaseUrl when exposing through HTTPS/reverse proxy.");
if (config.gateway.server.publicBaseUrl) console.log(`Public: ${config.gateway.server.publicBaseUrl.replace(/\/$/, "")}`);
else console.log("Set gateway.server.publicBaseUrl when exposing through HTTPS/reverse proxy.");
}
export async function webhookBaseUrl(config: AppConfig): Promise<string> {
return config.server.publicBaseUrl?.replace(/\/$/, "") || await publicBaseUrlHint(config) || localBaseUrl(config);
return config.gateway.server.publicBaseUrl?.replace(/\/$/, "") || await publicBaseUrlHint(config) || localBaseUrl(config);
}
-18
View File
@@ -1,18 +0,0 @@
import type { AppConfig } from "../config.js";
import { webhookBaseUrl } from "./net.js";
export async function printFeishu(config: AppConfig): Promise<void> {
const baseUrl = await webhookBaseUrl(config);
console.log("Feishu/Lark setup instructions");
console.log("");
console.log(`Webhook URL: ${baseUrl}/webhook/feishu`);
console.log("Event subscription: im.message.receive_v1");
console.log("Required config fields: platforms.feishu.appId, appSecret, verificationToken, botNames");
console.log("Do not paste appSecret into chats or logs.");
if (!config.platforms.feishu.enabled) {
console.log("Warning: platforms.feishu.enabled is false in this config.");
}
if (!config.server.publicBaseUrl) {
console.log("Warning: server.publicBaseUrl is empty; configure your public HTTPS URL before production use.");
}
}
+20 -1
View File
@@ -1,8 +1,10 @@
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { Writable } from "node:stream";
export interface PromptSession {
ask(question: string, defaultValue?: string): Promise<string>;
askSecret(question: string, hasExisting?: boolean): Promise<string>;
askBoolean(question: string, defaultValue?: boolean): Promise<boolean>;
askList(question: string, defaultValues?: string[]): Promise<string[]>;
choose<T>(question: string, choices: Choice<T>[], defaultIndex?: number): Promise<T>;
@@ -16,9 +18,14 @@ export interface Choice<T> {
}
export function createPromptSession(): PromptSession {
const rl = readline.createInterface({ input, output });
let rl = readline.createInterface({ input, output });
return {
ask: (question, defaultValue) => ask(rl, question, defaultValue),
askSecret: async (question, hasExisting) => {
rl.close();
try { return await askSecret(question, hasExisting); }
finally { rl = readline.createInterface({ input, output }); }
},
askBoolean: (question, defaultValue) => askBoolean(rl, question, defaultValue),
askList: (question, defaultValues) => askList(rl, question, defaultValues),
choose: (question, choices, defaultIndex) => choose(rl, question, choices, defaultIndex),
@@ -26,6 +33,18 @@ export function createPromptSession(): PromptSession {
};
}
async function askSecret(question: string, hasExisting = false): Promise<string> {
if (!input.isTTY || !output.isTTY) throw new Error("Secret input requires an interactive terminal");
const muted = new Writable({ write(_chunk, _encoding, callback) { callback(); } });
const secretRl = readline.createInterface({ input, output: muted, terminal: true });
output.write(`${question}${hasExisting ? " (leave blank to keep existing)" : ""}: `);
try {
const answer = (await secretRl.question("")).trim();
output.write("\n");
return answer;
} finally { secretRl.close(); }
}
export async function ask(rl: readline.Interface, question: string, defaultValue?: string): Promise<string> {
const suffix = defaultValue !== undefined && defaultValue !== "" ? ` [${defaultValue}]` : "";
const answer = (await rl.question(`${question}${suffix}: `)).trim();
+7 -21
View File
@@ -1,26 +1,12 @@
import type { AppConfig } from "../config.js";
import type { FeishuConfig } from "../config.js";
import type { PromptSession } from "./prompt.js";
export async function configureFeishu(
prompt: PromptSession,
existing: AppConfig["platforms"]["feishu"]
): Promise<AppConfig["platforms"]["feishu"]> {
console.log("\nFeishu/Lark setup");
console.log("Create a Feishu/Lark app, enable bot messaging, and subscribe to im.message.receive_v1.");
const appId = await prompt.ask("App ID", existing.appId && existing.appId !== "cli_xxx" ? existing.appId : undefined);
const appSecretAnswer = await prompt.ask(existing.appSecret ? "App Secret (leave blank to keep existing)" : "App Secret");
const verificationToken = await prompt.ask(
"Verification token",
existing.verificationToken && existing.verificationToken !== "replace-me" ? existing.verificationToken : undefined
);
const botNames = await prompt.askList("Bot display names, comma-separated", existing.botNames);
export async function configureFeishu(prompt: PromptSession, existing: FeishuConfig): Promise<FeishuConfig> {
return {
enabled: true,
appId,
appSecret: appSecretAnswer || existing.appSecret,
verificationToken,
botNames
type: "feishu",
appId: await prompt.ask("App ID", existing.appId),
appSecret: await prompt.askSecret("App Secret", Boolean(existing.appSecret)) || existing.appSecret,
verificationToken: await prompt.askSecret("Verification token", Boolean(existing.verificationToken)) || existing.verificationToken,
botNames: await prompt.askList("Bot display names, comma-separated", existing.botNames)
};
}
+52
View File
@@ -0,0 +1,52 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { isValidServerHost, parseConfig } from "../config.js";
export function parseServerHost(input: string): string {
const host = input.trim();
if (!isValidServerHost(host)) throw new Error("server host must be a hostname, IPv4 address, or unbracketed IPv6 address without a port");
return host;
}
export function parseServerPort(input: string): number {
const value = input.trim();
if (!/^[0-9]+$/.test(value)) throw new Error("server port must be a decimal integer from 1 to 65535");
const port = Number(value);
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) throw new Error("server port must be a decimal integer from 1 to 65535");
return port;
}
export function suggestAvailablePort(preferred: number, usedPorts: ReadonlySet<number>): number {
for (let port = preferred; port <= 65_535; port++) {
if (!usedPorts.has(port)) return port;
}
throw new Error(`No unassigned instance port is available at or above ${preferred}`);
}
export function defaultInstancesDirectory(): string {
const root = path.resolve(process.env.GORI_AGENT_ROOT || path.join(os.homedir(), ".gori-agent"));
return path.join(root, "instances");
}
export function collectUsedInstancePorts(instancesDirectory: string, excludeConfigPath?: string): Set<number> {
const used = new Set<number>();
const excluded = excludeConfigPath ? path.resolve(excludeConfigPath) : undefined;
let entries: fs.Dirent[];
try { entries = fs.readdirSync(instancesDirectory, { withFileTypes: true }); }
catch { return used; }
for (const entry of entries) {
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
const configFile = path.join(instancesDirectory, entry.name, "config.json");
if (excluded && path.resolve(configFile) === excluded) continue;
try {
const stat = fs.lstatSync(configFile);
if (!stat.isFile() || stat.isSymbolicLink()) continue;
const config = parseConfig(JSON.parse(fs.readFileSync(configFile, "utf8")) as unknown);
used.add(config.gateway.server.port);
} catch { /* Ignore invalid or unreadable peer instances. */ }
}
return used;
}
+4 -11
View File
@@ -1,15 +1,8 @@
import crypto from "node:crypto";
import type { AppConfig } from "../config.js";
import type { WebhookConfig } from "../config.js";
import type { PromptSession } from "./prompt.js";
export async function configureGenericWebhook(
prompt: PromptSession,
existing: AppConfig["platforms"]["webhook"]
): Promise<AppConfig["platforms"]["webhook"]> {
console.log("\nGeneric webhook setup");
console.log("Use POST /webhook/generic with optional X-Gori-Signature HMAC-SHA256 authentication.");
const generated = existing.secret && existing.secret !== "replace-me" ? existing.secret : crypto.randomBytes(24).toString("hex");
const secret = await prompt.ask("Webhook secret", generated);
return { enabled: true, secret };
export async function configureGenericWebhook(prompt: PromptSession, existing: WebhookConfig): Promise<WebhookConfig> {
const answer = await prompt.askSecret("Webhook secret (leave blank to generate)", Boolean(existing.secret));
return { type: "webhook", secret: answer || existing.secret || crypto.randomBytes(24).toString("hex") };
}
+4 -11
View File
@@ -1,15 +1,8 @@
import crypto from "node:crypto";
import type { AppConfig } from "../config.js";
import type { WeixinConfig } from "../config.js";
import type { PromptSession } from "./prompt.js";
export async function configureWeixin(
prompt: PromptSession,
existing: AppConfig["platforms"]["weixin"]
): Promise<AppConfig["platforms"]["weixin"]> {
console.log("\nPersonal WeChat external webhook setup");
console.log("Native personal WeChat integration is not included; use an external bridge that POSTs to /webhook/weixin.");
const generated = existing.secret && existing.secret !== "replace-me" ? existing.secret : crypto.randomBytes(24).toString("hex");
const secret = await prompt.ask("Bridge secret/reference", generated);
return { enabled: true, mode: "external-webhook", secret };
export async function configureWeixin(prompt: PromptSession, existing: WeixinConfig): Promise<WeixinConfig> {
const answer = await prompt.askSecret("Bridge secret (leave blank to generate)", Boolean(existing.secret));
return { type: "weixin", mode: "external-webhook", secret: answer || existing.secret || crypto.randomBytes(24).toString("hex") };
}
+165 -53
View File
@@ -1,66 +1,178 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { discoverBackends } from "../acp/discovery.js";
import type { AppConfig } from "../config.js";
import { createPromptSession } from "./prompt.js";
import { loadConfigFile, projectRoot, writeConfigFile } from "./config-file.js";
import type { AppConfig, PlatformConfig } from "../config.js";
import { createPromptSession, type PromptSession } from "./prompt.js";
import { loadConfigFile, loadExampleConfig, projectRoot, writeConfigFile } from "./config-file.js";
import { collectUsedInstancePorts, defaultInstancesDirectory, parseServerHost, parseServerPort, suggestAvailablePort } from "./setup-server.js";
const GORI_SKILL = "/home/ubuntu/gori-space/gori-deploy/.kimi-code/skills/gori-update/SKILL.md";
export interface SetupOptions {
botId?: string;
requireNew?: boolean;
writeWithoutConfirmation?: boolean;
prompt?: PromptSession;
discoverAgents?: typeof discoverBackends;
instancesDirectory?: string;
log?: (message: string) => void;
}
export async function runSetup(configPath?: string): Promise<void> {
const loaded = loadConfigFile(configPath);
const prompt = createPromptSession();
export async function runSetup(configPath?: string, options: SetupOptions = {}): Promise<boolean> {
const target = path.resolve(configPath || "config.json");
const configExists = fs.existsSync(target);
if (options.requireNew && configExists) throw new Error(`Config already exists: ${target}`);
const current = configExists ? loadConfigFile(target).config : loadExampleConfig();
if (configExists && options.botId && options.botId !== current.bot.id) {
throw new Error(`Cannot change existing bot.id '${current.bot.id}' to '${options.botId}'`);
}
const prompt = options.prompt || createPromptSession();
const discoverAgents = options.discoverAgents || discoverBackends;
const log = options.log || console.log;
try {
console.log("gori-agent ACP setup");
console.log(`Config target: ${loaded.path}`);
const discovered = await discoverBackends();
for (const backend of discovered) console.log(`- ${backend.id}: ${backend.status}${backend.version ? ` (${backend.version})` : ""}${backend.reason ? ` - ${backend.reason}` : ""}`);
const kimi = discovered.find((backend) => backend.id === "kimi" && backend.status === "ready");
if (!kimi) throw new Error("Kimi ACP backend is not available");
log("gori-agent Config v3 setup");
log(`Config target: ${target}`);
const discovered = await discoverAgents();
for (const agent of discovered) log(`- ${agent.id}: ${agent.status}${agent.version ? ` (${agent.version})` : ""}${agent.reason ? ` - ${agent.reason}` : ""}`);
const ready = discovered.filter((agent) => agent.status === "ready");
if (!configExists && ready.length === 0) throw new Error("No ACP agent is available");
const selected = configExists ? current.bot.agent : ready.find((agent) => agent.id === current.bot.agent.id) || ready[0];
const existingRole = loaded.config.roles.find((role) => role.id === loaded.config.defaultRole);
const workspace = path.resolve(await prompt.ask("Assistant workspace", existingRole?.workspace || projectRoot()));
const includeOps = await prompt.askBoolean("Include ops role with the existing gori-update skill", loaded.config.roles.some((role) => role.id === "ops"));
const roles: AppConfig["roles"] = [{
id: "assistant", backend: "kimi", workspace, persona: existingRole?.persona || "", skills: [],
policy: { permissionMode: "deny", allowedTools: [], allowedCommandPatterns: [] }
}];
const skills: AppConfig["skills"] = [];
if (includeOps) {
skills.push({ id: "gori-update", file: GORI_SKILL, maxBytes: 256_000 });
roles.push(opsRole());
}
const botId = configExists ? current.bot.id : options.botId || await prompt.ask("Bot ID");
const workspace = path.resolve(await prompt.ask("Bot workspace", current.bot.workspace.startsWith("/absolute/") ? projectRoot() : current.bot.workspace));
const persona = await prompt.ask("Bot persona", current.bot.persona);
const usedPorts = collectUsedInstancePorts(options.instancesDirectory || defaultInstancesDirectory(), target);
const server = await configureServer(prompt, current.gateway.server, usedPorts, !configExists, log);
const platformType = await prompt.choose("Platform", [
{ label: "QQ", value: "qq" as const },
{ label: "Feishu/Lark", value: "feishu" as const },
{ label: "WeCom", value: "wecom" as const },
{ label: "Generic webhook", value: "webhook" as const },
{ label: "Weixin bridge", value: "weixin" as const }
], ["qq", "feishu", "wecom", "webhook", "weixin"].indexOf(current.gateway.platform.type));
const platform = await configurePlatform(prompt, platformType, current.gateway.platform);
const isTemplate = current.bot.id === "BOT_ID";
const next: AppConfig = {
...loaded.config,
configVersion: 2,
backends: [{ id: "kimi", command: kimi.command, args: ["acp"], env: {} }],
skills,
defaultRole: "assistant",
roles,
platforms: structuredClone(loaded.config.platforms)
configVersion: 3,
bot: {
id: botId,
workspace,
persona,
agent: configExists ? current.bot.agent : {
id: selected!.id,
command: selected!.command,
args: selected!.args,
env: {}
},
skills: isTemplate ? [] : current.bot.skills,
permissions: isTemplate ? { mode: "deny", allowedTools: [], allowedCommandPatterns: [] } : current.bot.permissions
},
gateway: { server, policy: current.gateway.policy, platform },
runtime: current.runtime
};
console.log("\nMigration summary:");
console.log(`- default role: ${next.defaultRole}`);
console.log(`- roles: ${next.roles.map(({ id }) => id).join(", ")}`);
console.log(`- backend: ${kimi.command} acp`);
console.log("- all existing platform settings and credentials are preserved");
if (await prompt.askBoolean(`Write config to ${loaded.path}`, false)) {
writeConfigFile(loaded.path, next);
console.log(`Wrote ${loaded.path}`);
} else console.log("No changes written.");
const shouldWrite = options.writeWithoutConfirmation || await prompt.askBoolean(`Write Config v3 to ${target}`, false);
if (!shouldWrite) { log("No changes written."); return false; }
writeConfigFile(target, next);
log(`Wrote ${target} with mode 0600`);
return true;
} finally { prompt.close(); }
}
function opsRole(): AppConfig["roles"][number] {
return {
id: "ops", backend: "kimi", workspace: "/home/ubuntu/gori-space",
persona: "你是 Gori 团队运维角色。严格遵循 gori-update skill;有风险或需要外部确认时停止并报告。",
skills: ["gori-update"],
policy: {
permissionMode: "allowlist",
allowedTools: ["read", "grep", "glob", "bash"],
allowedCommandPatterns: [
"^(?:.*\\\"command\\\":\\\")?(?:git (?:status|log|diff|pull --ff-only)|bash gori-deploy/(?:build\\.sh|dist/deploy-[a-z-]+\\.sh)|docker (?:ps|logs))"
]
async function configureServer(
prompt: PromptSession,
existing: AppConfig["gateway"]["server"],
usedPorts: ReadonlySet<number>,
isNew: boolean,
log: (message: string) => void
): Promise<AppConfig["gateway"]["server"]> {
let host: string;
for (;;) {
try {
host = parseServerHost(await prompt.ask("Gateway server host", existing.host));
break;
} catch (error) {
log(`ERROR: ${error instanceof Error ? error.message : String(error)}`);
}
};
}
const preferredPort = existing.port;
let suggestedPort = preferredPort;
if (usedPorts.has(preferredPort)) {
try {
suggestedPort = suggestAvailablePort(preferredPort, usedPorts);
log(`WARNING: Gateway server port ${preferredPort} is already declared by another instance; suggested port: ${suggestedPort}.`);
} catch (error) {
if (isNew) throw error;
log(`WARNING: Gateway server port ${preferredPort} is already declared by another instance, and no higher unassigned port is available.`);
}
}
let port: number;
for (;;) {
try {
port = parseServerPort(await prompt.ask("Gateway server port", String(isNew ? suggestedPort : preferredPort)));
break;
} catch (error) {
log(`ERROR: ${error instanceof Error ? error.message : String(error)}`);
}
}
if (usedPorts.has(port)) {
log(`WARNING: Selected gateway server port ${port} is already declared by another instance; start will fail if that port is in use.`);
}
return { host, port, publicBaseUrl: existing.publicBaseUrl };
}
async function configurePlatform(
prompt: PromptSession,
type: PlatformConfig["type"],
existing: PlatformConfig
): Promise<PlatformConfig> {
switch (type) {
case "qq": {
const previous = existing.type === "qq" ? existing : undefined;
return {
type,
connectionMode: await prompt.choose("QQ connection mode", [
{ label: "WebSocket", value: "websocket" as const },
{ label: "Webhook", value: "webhook" as const }
], previous?.connectionMode === "webhook" ? 1 : 0),
appId: await prompt.ask("QQ App ID", previous?.appId),
clientSecret: await prompt.askSecret("QQ Client Secret", Boolean(previous?.clientSecret)) || previous?.clientSecret || "",
botSecret: await prompt.askSecret("QQ Bot Secret", Boolean(previous?.botSecret)) || previous?.botSecret || "",
verifySignature: previous?.verifySignature ?? true,
botNames: await prompt.askList("QQ Bot names", previous?.botNames),
intents: previous?.intents ?? 33_554_432,
shard: previous?.shard ?? [0, 1]
};
}
case "feishu": {
const previous = existing.type === "feishu" ? existing : undefined;
return {
type,
appId: await prompt.ask("Feishu App ID", previous?.appId),
appSecret: await prompt.askSecret("Feishu App Secret", Boolean(previous?.appSecret)) || previous?.appSecret || "",
verificationToken: await prompt.askSecret("Feishu verification token", Boolean(previous?.verificationToken)) || previous?.verificationToken || "",
botNames: await prompt.askList("Feishu Bot names", previous?.botNames)
};
}
case "wecom": {
const previous = existing.type === "wecom" ? existing : undefined;
return {
type,
corpId: await prompt.ask("WeCom Corp ID", previous?.corpId),
agentId: await prompt.ask("WeCom Agent ID", previous?.agentId),
secret: await prompt.askSecret("WeCom Secret", Boolean(previous?.secret)) || previous?.secret || ""
};
}
case "webhook": {
const previous = existing.type === "webhook" ? existing : undefined;
const secret = await prompt.askSecret("Webhook secret", Boolean(previous?.secret));
return { type, secret: secret || previous?.secret || crypto.randomBytes(24).toString("hex") };
}
case "weixin": {
const previous = existing.type === "weixin" ? existing : undefined;
const secret = await prompt.askSecret("Weixin bridge secret", Boolean(previous?.secret));
return { type, mode: "external-webhook", secret: secret || previous?.secret || crypto.randomBytes(24).toString("hex") };
}
}
}
+103 -106
View File
@@ -1,95 +1,120 @@
import fs from "node:fs";
import net from "node:net";
import path from "node:path";
import process from "node:process";
import { z } from "zod";
const policySchema = z.object({
const gatewayPolicySchema = z.object({
allowedUsers: z.array(z.string()).default([]),
allowedChats: z.array(z.string()).default([]),
requireMentionInGroup: z.boolean().default(false)
});
requireMentionInGroup: z.boolean().default(true)
}).strict();
const rolePolicySchema = z.object({
permissionMode: z.enum(["deny", "allowlist", "auto"]).default("deny"),
const permissionPolicySchema = z.object({
mode: z.enum(["deny", "allowlist", "auto"]).default("deny"),
allowedTools: z.array(z.string()).default([]),
allowedCommandPatterns: z.array(z.string()).default([])
});
}).strict();
const backendSchema = z.object({
const agentSchema = z.object({
id: z.string().min(1),
command: z.string().min(1),
args: z.array(z.string()).default([]),
env: z.record(z.string()).default({})
});
}).strict();
const skillSchema = z.object({
id: z.string().min(1),
file: z.string().min(1),
maxBytes: z.number().int().positive().default(256_000)
});
}).strict();
const roleSchema = z.object({
id: z.string().min(1),
backend: z.string().min(1),
const botIdSchema = z.union([
z.literal("BOT_ID"),
z.string().regex(/^[a-z0-9](?:[a-z0-9-]{0,62})$/, "bot.id must contain only lowercase letters, digits, and hyphens")
]);
const botSchema = z.object({
id: botIdSchema,
workspace: z.string().min(1),
persona: z.string().default(""),
skills: z.array(z.string()).default([]),
policy: rolePolicySchema.default({})
});
agent: agentSchema,
skills: z.array(skillSchema).default([]),
permissions: permissionPolicySchema.default({})
}).strict();
const serverSchema = z.object({
host: z.string().refine(isValidServerHost, "server host must be a hostname, IPv4 address, or unbracketed IPv6 address without a port").default("0.0.0.0"),
port: z.number().int().positive().max(65_535).default(8787),
publicBaseUrl: z.string().default("")
}).strict();
const feishuSchema = z.object({
type: z.literal("feishu"),
appId: z.string().default(""),
appSecret: z.string().default(""),
verificationToken: z.string().default(""),
botNames: z.array(z.string()).default([])
}).strict();
const wecomSchema = z.object({
type: z.literal("wecom"),
corpId: z.string().default(""),
agentId: z.string().default(""),
secret: z.string().default("")
}).strict();
const qqSchema = z.object({
type: z.literal("qq"),
connectionMode: z.enum(["websocket", "webhook"]).default("websocket"),
appId: z.string().default(""),
clientSecret: z.string().default(""),
botSecret: z.string().default(""),
verifySignature: z.boolean().default(true),
botNames: z.array(z.string()).default([]),
intents: z.number().int().positive().default(1 << 25),
shard: z.tuple([z.number().int().nonnegative(), z.number().int().positive()]).default([0, 1])
}).strict();
const webhookSchema = z.object({ type: z.literal("webhook"), secret: z.string().default("") }).strict();
const weixinSchema = z.object({
type: z.literal("weixin"),
mode: z.enum(["external-webhook", "not-implemented"]).default("external-webhook"),
secret: z.string().default("")
}).strict();
const platformSchema = z.discriminatedUnion("type", [feishuSchema, wecomSchema, qqSchema, webhookSchema, weixinSchema]);
const acpSchema = z.object({
stateFile: z.string().default(""),
initializeTimeoutMs: z.number().int().positive().default(10_000),
promptTimeoutMs: z.number().int().positive().default(600_000),
promptTimeoutMs: z.number().int().positive().default(7_200_000),
cancelGraceMs: z.number().int().positive().default(5_000),
idleTimeoutMs: z.number().int().positive().default(1_800_000),
sweepIntervalMs: z.number().int().positive().default(60_000),
maxProcesses: z.number().int().positive().default(8)
});
const feishuSchema = z.object({
enabled: z.boolean().default(false), appId: z.string().default(""), appSecret: z.string().default(""),
verificationToken: z.string().default(""), botNames: z.array(z.string()).default([])
});
const wecomSchema = z.object({
enabled: z.boolean().default(false), corpId: z.string().default(""), agentId: z.string().default(""), secret: z.string().default("")
});
const qqSchema = z.object({
enabled: z.boolean().default(false), connectionMode: z.enum(["websocket", "webhook"]).default("websocket"),
appId: z.string().default(""), clientSecret: z.string().default(""), botSecret: z.string().default(""),
verifySignature: z.boolean().default(true), botNames: z.array(z.string()).default([]),
intents: z.number().int().positive().default(1 << 25),
shard: z.tuple([z.number().int().nonnegative(), z.number().int().positive()]).default([0, 1])
});
const webhookSchema = z.object({ enabled: z.boolean().default(true), secret: z.string().default("") });
const weixinSchema = z.object({
enabled: z.boolean().default(false), mode: z.enum(["external-webhook", "not-implemented"]).default("external-webhook"), secret: z.string().default("")
});
}).strict();
export const configSchema = z.object({
configVersion: z.literal(2),
server: z.object({
host: z.string().default("0.0.0.0"), port: z.number().int().positive().max(65_535).default(3000), publicBaseUrl: z.string().default("")
}).default({}),
policy: policySchema.default({}),
acp: acpSchema.default({}),
backends: z.array(backendSchema).min(1),
skills: z.array(skillSchema).default([]),
defaultRole: z.string().min(1),
roles: z.array(roleSchema).min(1),
platforms: z.object({
feishu: feishuSchema.default({}), wecom: wecomSchema.default({}), qq: qqSchema.default({}),
webhook: webhookSchema.default({}), weixin: weixinSchema.default({})
}).default({})
});
configVersion: z.literal(3),
bot: botSchema,
gateway: z.object({
server: serverSchema.default({}),
policy: gatewayPolicySchema.default({}),
platform: platformSchema
}).strict(),
runtime: z.object({ acp: acpSchema.default({}) }).strict().default({})
}).strict();
export type AppConfig = z.infer<typeof configSchema>;
export type GatewayPolicy = z.infer<typeof policySchema>;
export type AcpConfig = AppConfig["acp"];
export type AcpBackendConfig = AppConfig["backends"][number];
export type RoleConfig = AppConfig["roles"][number];
export type RolePolicy = RoleConfig["policy"];
export type SkillConfig = AppConfig["skills"][number];
export type BotConfig = AppConfig["bot"];
export type AgentConfig = BotConfig["agent"];
export type PermissionPolicy = BotConfig["permissions"];
export type SkillConfig = BotConfig["skills"][number];
export type GatewayPolicy = AppConfig["gateway"]["policy"];
export type AcpConfig = AppConfig["runtime"]["acp"];
export type PlatformConfig = AppConfig["gateway"]["platform"];
export type PlatformType = PlatformConfig["type"];
export type QqConfig = Extract<PlatformConfig, { type: "qq" }>;
export type FeishuConfig = Extract<PlatformConfig, { type: "feishu" }>;
export type WeComConfig = Extract<PlatformConfig, { type: "wecom" }>;
export type WebhookConfig = Extract<PlatformConfig, { type: "webhook" }>;
export type WeixinConfig = Extract<PlatformConfig, { type: "weixin" }>;
// Kept only so legacy CliAgent source remains type-checkable; it is not used by the runtime.
export interface CliAgentConfig {
@@ -102,64 +127,29 @@ export interface CliAgentConfig {
cwd?: string;
}
interface V1AgentConfig {
name?: string;
command?: string;
args?: string[];
cwd?: string;
}
export function resolveConfigPath(configPath = process.env.GORI_GATEWAY_CONFIG): string {
return path.resolve(configPath || "config.example.json");
return path.resolve(configPath || "config.json");
}
export function parseConfig(rawConfig: unknown): AppConfig {
const candidate = isRecord(rawConfig) && rawConfig.configVersion === 2 ? rawConfig : migrateV1Config(rawConfig);
const config = configSchema.parse(candidate);
validateUnique(config.backends.map((item) => item.id), "backend");
validateUnique(config.roles.map((item) => item.id), "role");
validateUnique(config.skills.map((item) => item.id), "skill");
const backendIds = new Set(config.backends.map((item) => item.id));
const skillIds = new Set(config.skills.map((item) => item.id));
if (!config.roles.some((role) => role.id === config.defaultRole)) throw new Error(`defaultRole '${config.defaultRole}' is not present in roles`);
for (const role of config.roles) {
if (!path.isAbsolute(role.workspace)) throw new Error(`role '${role.id}' workspace must be absolute`);
if (!backendIds.has(role.backend)) throw new Error(`role '${role.id}' references unknown backend '${role.backend}'`);
for (const skill of role.skills) if (!skillIds.has(skill)) throw new Error(`role '${role.id}' references unknown skill '${skill}'`);
for (const pattern of role.policy.allowedCommandPatterns) {
try { new RegExp(pattern); } catch { throw new Error(`role '${role.id}' has invalid command pattern '${pattern}'`); }
}
if (!isRecord(rawConfig)) throw new Error("configuration must be an object");
if (rawConfig.configVersion !== 3) {
throw new Error(`unsupported configVersion '${String(rawConfig.configVersion)}'; gori-agent requires Config v3`);
}
const config = configSchema.parse(rawConfig);
if (!path.isAbsolute(config.bot.workspace)) throw new Error("bot.workspace must be absolute");
validateUnique(config.bot.skills.map((item) => item.id), "skill");
for (const skill of config.bot.skills) {
if (!path.isAbsolute(skill.file)) throw new Error(`skill '${skill.id}' file must be absolute`);
}
for (const pattern of config.bot.permissions.allowedCommandPatterns) {
try { new RegExp(pattern); } catch { throw new Error(`bot has invalid command pattern '${pattern}'`); }
}
return config;
}
export function migrateV1Config(rawConfig: unknown): unknown {
if (!isRecord(rawConfig)) throw new Error("configuration must be an object");
const agents = Array.isArray(rawConfig.agents) ? rawConfig.agents.filter(isRecord) as V1AgentConfig[] : [];
const defaultAgent = typeof rawConfig.defaultAgent === "string" ? rawConfig.defaultAgent : "";
const selected = agents.find((agent) => agent.name === defaultAgent);
if (!selected || selected.name !== "kimi" || !selected.command || path.basename(selected.command) !== "kimi") {
throw new Error("v1 migration only supports a Kimi default agent; configure an ACP backend explicitly for other agents");
}
return {
configVersion: 2,
server: rawConfig.server,
policy: rawConfig.policy,
acp: {},
backends: [{ id: "kimi", command: selected.command, args: ["acp"] }],
skills: [],
defaultRole: "assistant",
roles: [{
id: "assistant", backend: "kimi", workspace: path.resolve(selected.cwd || process.cwd()), persona: "", skills: [],
policy: { permissionMode: "deny", allowedTools: [], allowedCommandPatterns: [] }
}],
platforms: rawConfig.platforms
};
}
export function defaultStateFile(config: AppConfig): string {
if (config.acp.stateFile) return path.resolve(config.acp.stateFile);
if (config.runtime.acp.stateFile) return path.resolve(config.runtime.acp.stateFile);
const home = process.env.GORI_AGENT_HOME || process.cwd();
return path.join(path.resolve(home), "state", "acp-sessions.json");
}
@@ -172,6 +162,13 @@ export function loadConfigFromPath(configPath: string): AppConfig {
return parseConfig(JSON.parse(fs.readFileSync(configPath, "utf8")) as unknown);
}
export function isValidServerHost(value: string): boolean {
if (!value || value !== value.trim() || /[\s\u0000-\u001f\u007f/\\\[\]]/.test(value) || value.includes("://")) return false;
if (net.isIP(value) !== 0) return true;
if (value.length > 253 || value.includes(":")) return false;
return value.split(".").every((label) => /^(?=.{1,63}$)[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(label));
}
function validateUnique(ids: string[], label: string): void {
if (new Set(ids).size !== ids.length) throw new Error(`${label} IDs must be unique`);
}
+6 -8
View File
@@ -1,25 +1,23 @@
export type CommandKind = "help" | "roles" | "role" | "status" | "cancel" | "new";
export type CommandKind = "help" | "status" | "cancel" | "new" | "retired-role";
export interface ParsedCommand {
kind: CommandKind;
argument?: string;
deprecatedAlias?: boolean;
}
export class CommandRouter {
parse(text: string): ParsedCommand | undefined {
const trimmed = text.trim();
if (!trimmed.startsWith("/")) return undefined;
const [command, ...args] = trimmed.split(/\s+/);
const [command] = trimmed.split(/\s+/);
switch (command.toLowerCase()) {
case "/help": return { kind: "help" };
case "/roles": return { kind: "roles" };
case "/role": return { kind: "role", argument: args[0] };
case "/status": return { kind: "status" };
case "/cancel": return { kind: "cancel" };
case "/new": return { kind: "new" };
case "/agents": return { kind: "roles", deprecatedAlias: true };
case "/agent": return { kind: "role", argument: args[0], deprecatedAlias: true };
case "/roles":
case "/role":
case "/agents":
case "/agent": return { kind: "retired-role" };
default: return undefined;
}
}
+104 -50
View File
@@ -1,53 +1,72 @@
import fs from "node:fs";
import path from "node:path";
export interface ChatState {
selectedRole: string;
createdAt: number;
updatedAt: number;
export interface StoreIdentity {
botId: string;
platform: string;
}
export interface SessionBinding {
chatKey: string;
roleId: string;
backendId: string;
agentId: string;
nativeSessionId: string;
workspace: string;
roleFingerprint: string;
botFingerprint: string;
createdAt: number;
updatedAt: number;
}
interface StoreData {
version: 1;
chats: Record<string, ChatState>;
version: 2;
botId: string;
platform: string;
bindings: Record<string, SessionBinding>;
}
const EMPTY: StoreData = { version: 1, chats: {}, bindings: {} };
export class DurableSessionStore {
private data: StoreData = structuredClone(EMPTY);
private data: StoreData;
private queue: Promise<void> = Promise.resolve();
private lockFd?: fs.promises.FileHandle;
private closed = false;
constructor(readonly file: string) {}
constructor(readonly file: string, readonly identity: StoreIdentity) {
this.data = emptyData(identity);
}
async open(): Promise<void> {
await fs.promises.mkdir(path.dirname(this.file), { recursive: true });
const directory = path.dirname(this.file);
await fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
const directoryStat = await fs.promises.lstat(directory);
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) throw new Error(`Refusing unsafe state directory: ${directory}`);
const directoryMode = directoryStat.mode & 0o777;
if (directoryMode !== 0o700) throw new Error(`State directory mode must be 0700, got 0${directoryMode.toString(8)}`);
if (typeof process.getuid === "function" && directoryStat.uid !== process.getuid()) throw new Error("State directory is not owned by the current user");
const lockFile = `${this.file}.lock`;
try {
this.lockFd = await acquireLock(lockFile);
await this.lockFd.writeFile(`${process.pid}\n`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Session state is locked by another gori-agent instance: ${lockFile}`);
if ((error as NodeJS.ErrnoException).code !== "EEXIST" || !await removeStaleLock(lockFile)) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Session state is locked by another gori-agent instance: ${lockFile}`);
throw error;
}
this.lockFd = await acquireLock(lockFile).catch((retryError) => {
if ((retryError as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Session state is locked by another gori-agent instance: ${lockFile}`);
throw retryError;
});
}
try {
await this.lockFd.writeFile(`${process.pid}\n`);
await this.lockFd.sync();
} catch (error) {
await this.releaseLock();
throw error;
}
try {
const raw = await fs.promises.readFile(this.file, "utf8");
const parsed = JSON.parse(raw) as StoreData;
if (parsed.version !== 1 || !parsed.chats || !parsed.bindings) throw new Error("unsupported or malformed state data");
const parsed = parseStoreData(JSON.parse(raw) as unknown);
if (parsed.botId !== this.identity.botId || parsed.platform !== this.identity.platform) {
throw new Error(`state identity mismatch: expected bot '${this.identity.botId}' platform '${this.identity.platform}'`);
}
this.data = parsed;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
@@ -57,44 +76,32 @@ export class DurableSessionStore {
}
}
getSelectedRole(chatKey: string, defaultRole: string): string {
return this.data.chats[chatKey]?.selectedRole || defaultRole;
}
async setSelectedRole(chatKey: string, roleId: string): Promise<void> {
const now = Date.now();
const current = this.data.chats[chatKey];
this.data.chats[chatKey] = { selectedRole: roleId, createdAt: current?.createdAt || now, updatedAt: now };
await this.persist();
}
getBinding(chatKey: string, roleId: string): SessionBinding | undefined {
const value = this.data.bindings[bindingKey(chatKey, roleId)];
getBinding(chatKey: string): SessionBinding | undefined {
const value = this.data.bindings[chatKey];
return value ? structuredClone(value) : undefined;
}
async setBinding(binding: SessionBinding): Promise<void> {
this.data.bindings[bindingKey(binding.chatKey, binding.roleId)] = structuredClone(binding);
this.data.bindings[binding.chatKey] = structuredClone(binding);
await this.persist();
}
async touchBinding(chatKey: string, roleId: string): Promise<void> {
const binding = this.data.bindings[bindingKey(chatKey, roleId)];
async touchBinding(chatKey: string): Promise<void> {
const binding = this.data.bindings[chatKey];
if (!binding) return;
binding.updatedAt = Date.now();
await this.persist();
}
async deleteBinding(chatKey: string, roleId: string): Promise<SessionBinding | undefined> {
const key = bindingKey(chatKey, roleId);
const existing = this.data.bindings[key];
delete this.data.bindings[key];
async deleteBinding(chatKey: string): Promise<SessionBinding | undefined> {
const existing = this.data.bindings[chatKey];
delete this.data.bindings[chatKey];
if (existing) await this.persist();
return existing;
return existing ? structuredClone(existing) : undefined;
}
stats(): { chats: number; bindings: number } {
return { chats: Object.keys(this.data.chats).length, bindings: Object.keys(this.data.bindings).length };
stats(): { bindings: number } {
return { bindings: Object.keys(this.data.bindings).length };
}
async flush(): Promise<void> { await this.queue; }
@@ -102,15 +109,15 @@ export class DurableSessionStore {
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
await this.flush();
await this.releaseLock();
try { await this.flush(); } finally { await this.releaseLock(); }
}
private persist(): Promise<void> {
if (this.closed) return Promise.reject(new Error("Session store is closed"));
const snapshot = JSON.stringify(this.data, null, 2) + "\n";
this.queue = this.queue.then(() => atomicWrite(this.file, snapshot));
return this.queue;
const operation = this.queue.then(() => atomicWrite(this.file, snapshot));
this.queue = operation.catch(() => undefined);
return operation;
}
private async releaseLock(): Promise<void> {
@@ -124,26 +131,73 @@ export class DurableSessionStore {
}
export function chatKeyFor(platform: string, chatId: string): string { return `${platform}:${chatId}`; }
export function bindingKey(chatKey: string, roleId: string): string { return `${chatKey}\u0000${roleId}`; }
function emptyData(identity: StoreIdentity): StoreData {
return { version: 2, botId: identity.botId, platform: identity.platform, bindings: {} };
}
function parseStoreData(value: unknown): StoreData {
if (!isRecord(value) || value.version !== 2 || typeof value.botId !== "string" || typeof value.platform !== "string" || !isRecord(value.bindings)) {
throw new Error("unsupported state version; Config v3 requires state v2 in a new GORI_AGENT_HOME");
}
for (const [chatKey, binding] of Object.entries(value.bindings)) {
if (!isRecord(binding)
|| binding.chatKey !== chatKey
|| typeof binding.agentId !== "string"
|| typeof binding.nativeSessionId !== "string"
|| typeof binding.workspace !== "string"
|| typeof binding.botFingerprint !== "string"
|| typeof binding.createdAt !== "number"
|| typeof binding.updatedAt !== "number") {
throw new Error(`invalid state v2 binding '${chatKey}'`);
}
}
return value as unknown as StoreData;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function acquireLock(lockFile: string): Promise<fs.promises.FileHandle> {
return fs.promises.open(lockFile, "wx", 0o600);
}
async function removeStaleLock(lockFile: string): Promise<boolean> {
let handle: fs.promises.FileHandle | undefined;
try {
handle = await fs.promises.open(lockFile, "r");
const pid = Number((await handle.readFile("utf8")).trim());
if (!Number.isSafeInteger(pid) || pid <= 1 || processExists(pid)) return false;
const opened = await handle.stat();
const current = await fs.promises.lstat(lockFile);
if (opened.dev !== current.dev || opened.ino !== current.ino) return false;
await fs.promises.unlink(lockFile);
return true;
} catch { return false; }
finally { await handle?.close().catch(() => undefined); }
}
function processExists(pid: number): boolean {
try { process.kill(pid, 0); return true; }
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
}
async function atomicWrite(file: string, content: string): Promise<void> {
const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
const handle = await fs.promises.open(temp, "wx", 0o600);
let handle: fs.promises.FileHandle | undefined;
try {
handle = await fs.promises.open(temp, "wx", 0o600);
await handle.writeFile(content, "utf8");
await handle.sync();
} finally {
await handle.close();
}
try {
handle = undefined;
await fs.promises.rename(temp, file);
await fs.promises.chmod(file, 0o600);
const dir = await fs.promises.open(path.dirname(file), "r");
try { await dir.sync(); } finally { await dir.close(); }
} catch (error) {
if (handle) await handle.close().catch(() => undefined);
await fs.promises.unlink(temp).catch(() => undefined);
throw error;
}
+7 -15
View File
@@ -1,6 +1,5 @@
import type { ConversationRuntime } from "../acp/types.js";
import type { GatewayPolicy } from "../config.js";
import type { RoleRegistry } from "../roles/role-registry.js";
import type { PlatformAdapter } from "./adapter.js";
import { CommandRouter, type ParsedCommand } from "./command-router.js";
import { chatKeyFor } from "./durable-session-store.js";
@@ -12,12 +11,12 @@ export class Gateway {
private readonly locks = new Map<string, Promise<void>>();
readonly commandRouter = new CommandRouter();
constructor(private readonly policy: GatewayPolicy, private readonly runtime: ConversationRuntime, private readonly roles: RoleRegistry) {}
constructor(private readonly policy: GatewayPolicy, private readonly runtime: ConversationRuntime) {}
async receive(message: IncomingMessage, adapter: PlatformAdapter, options: { synchronous?: boolean } = {}): Promise<GatewayResult> {
const policyError = this.checkPolicy(message);
if (policyError) {
console.log(`Message ignored by policy: ${policyError} (${message.platform} ${message.chatId} ${message.userId})`);
console.log(`Message ignored by policy: ${policyError} (platform=${message.platform})`);
return { ok: true, ignored: true, error: policyError };
}
@@ -43,24 +42,17 @@ export class Gateway {
stats(): ReturnType<ConversationRuntime["stats"]> & { lockedChats: number } { return { ...this.runtime.stats(), lockedChats: this.locks.size }; }
private async executeCommand(command: ParsedCommand, message: IncomingMessage): Promise<string> {
const prefix = command.deprecatedAlias ? "Deprecated alias; use /role or /roles.\n" : "";
switch (command.kind) {
case "help": return ["Commands:", "/roles", "/role <id>", "/status", "/cancel", "/new", "/help"].join("\n");
case "roles": return `${prefix}Available roles: ${this.roles.list().join(", ")}`;
case "role":
if (!command.argument) return `${prefix}Usage: /role <id>`;
if (!this.roles.has(command.argument)) return `${prefix}Unknown role: ${command.argument}`;
await this.runtime.cancel(message.platform, message.chatId);
await this.runtime.selectRole(message.platform, message.chatId, command.argument);
return `${prefix}Selected role: ${command.argument}`;
case "help": return ["Commands:", "/status", "/cancel", "/new", "/help"].join("\n");
case "status": {
const status = this.runtime.status(message.platform, message.chatId);
return `OK\n${Object.entries(status).map(([key, value]) => `${key}=${value}`).join("\n")}`;
}
case "new":
await this.runtime.reset(message.platform, message.chatId);
return "Started a new native ACP session for this chat and role.";
return "Started a new native ACP session for this chat.";
case "cancel": return this.cancelText(message);
case "retired-role": return "Role switching was removed in Config v3; this instance has one fixed Bot and ACP agent.";
}
}
@@ -78,8 +70,8 @@ export class Gateway {
}
private checkPolicy(message: IncomingMessage): string | undefined {
if (this.policy.allowedUsers.length > 0 && !this.policy.allowedUsers.includes(message.userId)) return `User not allowed: ${message.userId}`;
if (this.policy.allowedChats.length > 0 && !this.policy.allowedChats.includes(message.chatId)) return `Chat not allowed: ${message.chatId}`;
if (this.policy.allowedUsers.length > 0 && !this.policy.allowedUsers.includes(message.userId)) return "User not allowed";
if (this.policy.allowedChats.length > 0 && !this.policy.allowedChats.includes(message.chatId)) return "Chat not allowed";
if (this.policy.requireMentionInGroup && message.isGroup && !message.mentionsBot) return "Mention required in group chat";
return undefined;
}
+4 -8
View File
@@ -1,4 +1,4 @@
import type { AppConfig } from "../../config.js";
import type { FeishuConfig } from "../../config.js";
import { jsonResponse } from "../../core/adapter.js";
import type { PlatformAdapter } from "../../core/adapter.js";
import type { Gateway } from "../../core/gateway.js";
@@ -11,7 +11,7 @@ export class FeishuAdapter implements PlatformAdapter {
private tenantToken?: { token: string; expiresAt: number };
constructor(
private readonly config: AppConfig["platforms"]["feishu"],
private readonly config: FeishuConfig,
private readonly gateway: Gateway
) {}
@@ -72,9 +72,7 @@ export class FeishuAdapter implements PlatformAdapter {
})
});
if (!response.ok) {
throw new Error(`Feishu reply failed: ${response.status} ${await response.text()}`);
}
if (!response.ok) throw new Error(`Feishu reply failed: HTTP ${response.status}`);
const data = await response.json() as { code?: number; msg?: string };
if (data.code && data.code !== 0) {
throw new Error(`Feishu reply failed: ${data.code} ${data.msg || ""}`.trim());
@@ -90,9 +88,7 @@ export class FeishuAdapter implements PlatformAdapter {
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ app_id: this.config.appId, app_secret: this.config.appSecret })
});
if (!response.ok) {
throw new Error(`Feishu token request failed: ${response.status} ${await response.text()}`);
}
if (!response.ok) throw new Error(`Feishu token request failed: HTTP ${response.status}`);
const data = await response.json() as FeishuTenantTokenResponse;
if (data.code !== 0 || !data.tenant_access_token) {
throw new Error(`Feishu token request failed: ${data.code} ${data.msg || ""}`.trim());
+6 -6
View File
@@ -1,4 +1,4 @@
import type { AppConfig } from "../../config.js";
import type { QqConfig } from "../../config.js";
import { jsonResponse } from "../../core/adapter.js";
import type { PlatformAdapter } from "../../core/adapter.js";
import type { Gateway } from "../../core/gateway.js";
@@ -18,7 +18,7 @@ export class QqAdapter implements PlatformAdapter {
private accessToken?: { token: string; expiresAt: number };
constructor(
private readonly config: AppConfig["platforms"]["qq"],
private readonly config: QqConfig,
private readonly gateway: Gateway
) {}
@@ -48,7 +48,7 @@ export class QqAdapter implements PlatformAdapter {
console.log(`QQ recv ${payload.t} ignored (empty text or missing ids)`);
return false;
}
console.log(`QQ recv ${payload.t} chat=${message.chatId} user=${message.userId} msgId=${message.messageId || ""} text=${JSON.stringify(message.text)}`);
console.log(`QQ recv ${payload.t} accepted length=${message.text.length}`);
void this.gateway.receive(message, this).catch((error) => {
console.error("QQ gateway error", error);
});
@@ -64,7 +64,7 @@ export class QqAdapter implements PlatformAdapter {
const isGroup = Boolean(groupOpenId) || message.target.chatId.startsWith("group:");
const targetId = groupOpenId || userOpenId || message.target.chatId.replace(/^group:/, "").replace(/^user:/, "");
const path = isGroup ? `/v2/groups/${encodeURIComponent(targetId)}/messages` : `/v2/users/${encodeURIComponent(targetId)}/messages`;
console.log(`QQ send -> ${path} msgId=${message.replyTo || ""} length=${message.text.length}`);
console.log(`QQ send ${isGroup ? "group" : "user"} message length=${message.text.length}`);
const response = await fetch(`https://api.sgroup.qq.com${path}`, {
method: "POST",
@@ -74,7 +74,7 @@ export class QqAdapter implements PlatformAdapter {
},
body: JSON.stringify({ content: message.text, msg_id: message.replyTo })
});
if (!response.ok) throw new Error(`QQ send failed: ${response.status} ${await response.text()}`);
if (!response.ok) throw new Error(`QQ send failed: HTTP ${response.status}`);
const data = await response.json() as QqSendMessageResponse;
if (data.code && data.code !== 0) throw new Error(`QQ send failed: ${data.code} ${data.message || ""}`.trim());
}
@@ -128,7 +128,7 @@ export class QqAdapter implements PlatformAdapter {
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ appId: this.config.appId, clientSecret: this.config.clientSecret })
});
if (!response.ok) throw new Error(`QQ token request failed: ${response.status} ${await response.text()}`);
if (!response.ok) throw new Error(`QQ token request failed: HTTP ${response.status}`);
const data = await response.json() as QqAccessTokenResponse;
if (!data.access_token) {
throw new Error(`QQ token request failed: ${data.error || "unknown"} ${data.error_description || ""}`.trim());
+3 -3
View File
@@ -1,6 +1,6 @@
import os from "node:os";
import WebSocket from "ws";
import type { AppConfig } from "../../config.js";
import type { QqConfig } from "../../config.js";
import type { QqAdapter } from "./adapter.js";
import type { QqGatewayResponse, QqWebhookPayload } from "./types.js";
@@ -19,7 +19,7 @@ export class QqGatewayClient {
private stopped = false;
constructor(
private readonly config: AppConfig["platforms"]["qq"],
private readonly config: QqConfig,
private readonly adapter: QqAdapter
) {}
@@ -113,7 +113,7 @@ export class QqGatewayClient {
const response = await fetch(QQ_GATEWAY_API, {
headers: { Authorization: `QQBot ${token}` }
});
if (!response.ok) throw new Error(`QQ gateway request failed: ${response.status} ${await response.text()}`);
if (!response.ok) throw new Error(`QQ gateway request failed: HTTP ${response.status}`);
return response.json() as Promise<QqGatewayResponse>;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import crypto from "node:crypto";
import type { AppConfig } from "../../config.js";
import type { WebhookConfig } from "../../config.js";
import { jsonResponse } from "../../core/adapter.js";
import type { PlatformAdapter } from "../../core/adapter.js";
import type { Gateway } from "../../core/gateway.js";
@@ -32,7 +32,7 @@ export class GenericWebhookAdapter implements PlatformAdapter {
readonly name = "webhook";
constructor(
private readonly config: AppConfig["platforms"]["webhook"],
private readonly config: WebhookConfig,
private readonly gateway: Gateway
) {}
+4 -4
View File
@@ -1,4 +1,4 @@
import type { AppConfig } from "../../config.js";
import type { WeComConfig } from "../../config.js";
import { notImplemented } from "../../core/adapter.js";
import type { PlatformAdapter } from "../../core/adapter.js";
import type { OutgoingMessage, WebhookRequestContext, WebhookResponse } from "../../core/types.js";
@@ -8,7 +8,7 @@ export class WeComAdapter implements PlatformAdapter {
readonly name = "wecom";
private accessToken?: { token: string; expiresAt: number };
constructor(private readonly config: AppConfig["platforms"]["wecom"]) {}
constructor(private readonly config: WeComConfig) {}
async handleWebhook(_context: WebhookRequestContext): Promise<WebhookResponse> {
return notImplemented("WeCom");
@@ -27,7 +27,7 @@ export class WeComAdapter implements PlatformAdapter {
safe: 0
})
});
if (!response.ok) throw new Error(`WeCom send failed: ${response.status} ${await response.text()}`);
if (!response.ok) throw new Error(`WeCom send failed: HTTP ${response.status}`);
const data = await response.json() as WeComSendMessageResponse;
if (data.errcode !== 0) throw new Error(`WeCom send failed: ${data.errcode} ${data.errmsg || ""}`.trim());
}
@@ -39,7 +39,7 @@ export class WeComAdapter implements PlatformAdapter {
url.searchParams.set("corpid", this.config.corpId);
url.searchParams.set("corpsecret", this.config.secret);
const response = await fetch(url);
if (!response.ok) throw new Error(`WeCom token request failed: ${response.status} ${await response.text()}`);
if (!response.ok) throw new Error(`WeCom token request failed: HTTP ${response.status}`);
const data = await response.json() as WeComAccessTokenResponse;
if (data.errcode !== 0 || !data.access_token) {
throw new Error(`WeCom token request failed: ${data.errcode} ${data.errmsg || ""}`.trim());
+2 -2
View File
@@ -1,4 +1,4 @@
import type { AppConfig } from "../../config.js";
import type { WeixinConfig } from "../../config.js";
import { jsonResponse, notImplemented } from "../../core/adapter.js";
import type { PlatformAdapter } from "../../core/adapter.js";
import type { Gateway } from "../../core/gateway.js";
@@ -17,7 +17,7 @@ export class WeixinAdapter implements PlatformAdapter {
readonly name = "weixin";
constructor(
private readonly config: AppConfig["platforms"]["weixin"],
private readonly config: WeixinConfig,
private readonly gateway: Gateway
) {}
+29 -35
View File
@@ -1,51 +1,45 @@
import crypto from "node:crypto";
import type { AppConfig, RoleConfig } from "../config.js";
import type { AppConfig, BotConfig } from "../config.js";
import { SkillLoader, type LoadedSkill } from "./skill-loader.js";
export interface ResolvedRole extends RoleConfig {
const BOOTSTRAP_SCHEMA_VERSION = 1;
export interface ResolvedBot extends BotConfig {
loadedSkills: LoadedSkill[];
fingerprint: string;
bootstrap: string;
}
export class RoleRegistry {
private readonly roles = new Map<string, ResolvedRole>();
export class BotProfileResolver {
readonly bot: ResolvedBot;
constructor(private readonly config: AppConfig) {
const loader = new SkillLoader(config.skills);
for (const role of config.roles) {
const loadedSkills = role.skills.map((id) => loader.load(id));
const fingerprint = crypto.createHash("sha256").update(JSON.stringify({
id: role.id,
backend: role.backend,
workspace: role.workspace,
persona: role.persona,
policy: role.policy,
skills: loadedSkills.map(({ id, file, hash }) => ({ id, file, hash }))
})).digest("hex");
this.roles.set(role.id, { ...role, loadedSkills, fingerprint, bootstrap: buildBootstrap(role, loadedSkills) });
}
constructor(config: AppConfig) {
const loadedSkills = config.bot.skills.map((skill) => new SkillLoader(config.bot.skills).load(skill.id));
const fingerprint = crypto.createHash("sha256").update(JSON.stringify({
bootstrapSchemaVersion: BOOTSTRAP_SCHEMA_VERSION,
id: config.bot.id,
workspace: config.bot.workspace,
persona: config.bot.persona,
agent: config.bot.agent,
permissions: config.bot.permissions,
skills: loadedSkills.map(({ id, file, hash }) => ({ id, file, hash }))
})).digest("hex");
this.bot = {
...config.bot,
loadedSkills,
fingerprint,
bootstrap: buildBootstrap(config.bot, loadedSkills)
};
}
get(id?: string): ResolvedRole {
const roleId = id || this.config.defaultRole;
const role = this.roles.get(roleId);
if (!role) throw new Error(`Unknown role: ${roleId}`);
return role;
}
has(id: string): boolean { return this.roles.has(id); }
list(): string[] { return [...this.roles.keys()].sort(); }
defaultId(): string { return this.config.defaultRole; }
}
function buildBootstrap(role: RoleConfig, skills: LoadedSkill[]): string {
function buildBootstrap(bot: BotConfig, skills: LoadedSkill[]): string {
return [
"Initialize this ACP session with the following role. Treat these instructions as persistent context. Reply only with READY.",
`Role: ${role.id}`,
`Workspace: ${role.workspace}`,
role.persona ? `Persona:\n${role.persona}` : "Persona: general coding assistant",
`Permission policy enforced by the ACP client: ${JSON.stringify(role.policy)}`,
`Initialize this ACP session with gori-agent bootstrap schema v${BOOTSTRAP_SCHEMA_VERSION}. Treat these instructions as persistent context. Reply only with READY.`,
`Bot: ${bot.id}`,
`Workspace: ${bot.workspace}`,
bot.persona ? `Persona:\n${bot.persona}` : "Persona: general coding assistant",
`Permission policy enforced by the ACP client: ${JSON.stringify(bot.permissions)}`,
...skills.map((skill) => `Skill ${skill.id} (${skill.file}):\n${skill.content}`)
].join("\n\n");
}
+77 -49
View File
@@ -1,103 +1,131 @@
import express from "express";
import type { Server } from "node:http";
import { fileURLToPath } from "node:url";
import { AcpBackendRegistry } from "./acp/backend-registry.js";
import { AcpSessionManager } from "./acp/session-manager.js";
import { defaultStateFile, loadConfig, type AppConfig } from "./config.js";
import type { PlatformAdapter } from "./core/adapter.js";
import { DurableSessionStore } from "./core/durable-session-store.js";
import { Gateway } from "./core/gateway.js";
import { PlatformRegistry } from "./core/platform-registry.js";
import { FeishuAdapter } from "./platforms/feishu/adapter.js";
import { QqAdapter } from "./platforms/qq/adapter.js";
import { QqGatewayClient } from "./platforms/qq/gateway-client.js";
import { WeComAdapter } from "./platforms/wecom/adapter.js";
import { GenericWebhookAdapter } from "./platforms/webhook/adapter.js";
import { WeixinAdapter } from "./platforms/weixin/adapter.js";
import { RoleRegistry } from "./roles/role-registry.js";
import { BotProfileResolver } from "./roles/role-registry.js";
export interface GatewayRuntime {
app: express.Express;
gateway: Gateway;
sessionManager: AcpSessionManager;
store: DurableSessionStore;
platformAdapter: PlatformAdapter;
qqGatewayClient?: QqGatewayClient;
shutdown(): Promise<void>;
}
export async function createGatewayRuntime(config: AppConfig): Promise<GatewayRuntime> {
const store = new DurableSessionStore(defaultStateFile(config));
const platformType = config.gateway.platform.type;
const store = new DurableSessionStore(defaultStateFile(config), { botId: config.bot.id, platform: platformType });
await store.open();
const roles = new RoleRegistry(config);
const sessionManager = new AcpSessionManager(config.acp, new AcpBackendRegistry(config.backends), roles, store);
const gateway = new Gateway(config.policy, sessionManager, roles);
const platforms = new PlatformRegistry();
const qqAdapter = new QqAdapter(config.platforms.qq, gateway);
const adapters: PlatformAdapter[] = [
new FeishuAdapter(config.platforms.feishu, gateway), new WeComAdapter(config.platforms.wecom), qqAdapter,
new GenericWebhookAdapter(config.platforms.webhook, gateway), new WeixinAdapter(config.platforms.weixin, gateway)
];
for (const adapter of adapters) platforms.register(adapter);
try {
const bot = new BotProfileResolver(config).bot;
const sessionManager = new AcpSessionManager(config.runtime.acp, bot, store);
const gateway = new Gateway(config.gateway.policy, sessionManager);
const platformAdapter = createPlatformAdapter(config, gateway);
const qqGatewayClient = config.gateway.platform.type === "qq" && config.gateway.platform.connectionMode === "websocket"
? new QqGatewayClient(config.gateway.platform, platformAdapter as QqAdapter) : undefined;
const app = express();
app.use(express.json({ limit: "1mb", verify: (req, _res, buf) => {
(req as express.Request & { rawBody?: Buffer }).rawBody = Buffer.from(buf);
} }));
app.get("/health", (_req, res) => res.json({ ok: true, acp: gateway.stats() }));
app.get("/platforms", (_req, res) => res.json({ ok: true, platforms: platforms.list(), roles: roles.list(), backends: config.backends.map(({ id }) => id) }));
const app = express();
app.use(express.json({ limit: "1mb", verify: (req, _res, buf) => {
(req as express.Request & { rawBody?: Buffer }).rawBody = Buffer.from(buf);
} }));
app.get("/health", (_req, res) => res.json({
ok: true,
configVersion: config.configVersion,
botId: config.bot.id,
platform: platformType,
acp: gateway.stats()
}));
app.get("/platforms", (_req, res) => res.json({ ok: true, botId: config.bot.id, platform: platformType }));
function mountWebhook(routeName: string, adapterName = routeName): void {
app.post(`/webhook/${routeName}`, async (req, res) => {
const routeName = platformType === "webhook" ? "generic" : platformType;
const needsWebhook = config.gateway.platform.type !== "qq" || config.gateway.platform.connectionMode === "webhook";
if (needsWebhook) app.post(`/webhook/${routeName}`, async (req, res) => {
try {
const response = await platforms.get(adapterName).handleWebhook({
const response = await platformAdapter.handleWebhook({
req, body: req.body, headers: req.headers, query: req.query,
rawBody: (req as express.Request & { rawBody?: Buffer }).rawBody
});
if (response.headers) for (const [key, value] of Object.entries(response.headers)) res.setHeader(key, value);
res.status(response.status || 200).json(response.body ?? { ok: true });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Webhook ${routeName} failed`, error);
res.status(500).json({ ok: false, error: message });
console.error(`Webhook ${routeName} failed (${error instanceof Error ? error.name : "unknown error"})`);
res.status(500).json({ ok: false, error: "Internal webhook error" });
}
});
}
for (const name of ["feishu", "wecom", "qq", "weixin"]) mountWebhook(name);
mountWebhook("generic", "webhook");
const qqGatewayClient = config.platforms.qq.enabled && config.platforms.qq.connectionMode === "websocket"
? new QqGatewayClient(config.platforms.qq, qqAdapter) : undefined;
let closing: Promise<void> | undefined;
const runtime: GatewayRuntime = {
app, gateway, sessionManager, store, qqGatewayClient,
shutdown: () => closing ||= (async () => {
qqGatewayClient?.stop();
await sessionManager.shutdown();
await store.close();
})()
};
return runtime;
let closing: Promise<void> | undefined;
return {
app, gateway, sessionManager, store, platformAdapter, qqGatewayClient,
shutdown: () => closing ||= (async () => {
qqGatewayClient?.stop();
const results = await Promise.allSettled([sessionManager.shutdown(), store.close()]);
const failed = results.find((result): result is PromiseRejectedResult => result.status === "rejected");
if (failed) throw failed.reason;
})()
};
} catch (error) {
await store.close();
throw error;
}
}
export async function createApp(config: AppConfig): Promise<express.Express> { return (await createGatewayRuntime(config)).app; }
function createPlatformAdapter(config: AppConfig, gateway: Gateway): PlatformAdapter {
const platform = config.gateway.platform;
switch (platform.type) {
case "feishu": return new FeishuAdapter(platform, gateway);
case "wecom": return new WeComAdapter(platform);
case "qq": return new QqAdapter(platform, gateway);
case "webhook": return new GenericWebhookAdapter(platform, gateway);
case "weixin": return new WeixinAdapter(platform, gateway);
}
}
export interface RunningServer { server: Server; runtime: GatewayRuntime; shutdown(): Promise<void> }
export async function startServer(config: AppConfig): Promise<RunningServer> {
const runtime = await createGatewayRuntime(config);
const server = runtime.app.listen(config.server.port, config.server.host, () => {
console.log(`gori-agent listening on ${config.server.host}:${config.server.port}`);
if (runtime.qqGatewayClient) { console.log("QQ websocket gateway enabled; connecting to QQ..."); runtime.qqGatewayClient.start(); }
});
const server = runtime.app.listen(config.gateway.server.port, config.gateway.server.host);
try {
await new Promise<void>((resolve, reject) => {
server.once("listening", resolve);
server.once("error", reject);
});
console.log(`gori-agent '${config.bot.id}' listening on ${config.gateway.server.host}:${config.gateway.server.port}`);
if (runtime.qqGatewayClient) {
console.log("QQ websocket gateway enabled; connecting to QQ...");
runtime.qqGatewayClient.start();
}
} catch (error) {
if (server.listening) await closeServer(server).catch(() => undefined);
await runtime.shutdown().catch(() => undefined);
throw error;
}
let closing: Promise<void> | undefined;
return { server, runtime, shutdown: () => closing ||= (async () => {
runtime.qqGatewayClient?.stop();
const runtimeShutdown = runtime.shutdown();
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
await runtimeShutdown;
const results = await Promise.allSettled([closeServer(server), runtime.shutdown()]);
const failed = results.find((result): result is PromiseRejectedResult => result.status === "rejected");
if (failed) throw failed.reason;
})() };
}
function closeServer(server: Server): Promise<void> {
if (!server.listening) return Promise.resolve();
return new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
void startServer(loadConfig()).catch((error) => { console.error(error); process.exitCode = 1; });
}
+15 -6
View File
@@ -3,9 +3,9 @@ import test from "node:test";
import type { RequestPermissionRequest } from "@agentclientprotocol/sdk";
import { decidePermission } from "../src/acp/client.js";
const request = (rawInput: unknown): RequestPermissionRequest => ({
const request = (rawInput: unknown, overrides: Partial<RequestPermissionRequest["toolCall"]> = {}): RequestPermissionRequest => ({
sessionId: "session",
toolCall: { toolCallId: "call", title: "bash git status", kind: "execute", name: "bash", rawInput },
toolCall: { toolCallId: "call", title: "bash git status", kind: "execute", name: "bash", rawInput, ...overrides },
options: [
{ optionId: "allow", name: "Allow", kind: "allow_once" },
{ optionId: "reject", name: "Reject", kind: "reject_once" }
@@ -13,11 +13,20 @@ const request = (rawInput: unknown): RequestPermissionRequest => ({
});
test("permission deny and allowlist fail closed", () => {
assert.equal(decidePermission(request("git status"), { permissionMode: "deny", allowedTools: [], allowedCommandPatterns: [] }).outcome.outcome, "selected");
const allowed = decidePermission(request("git status"), { permissionMode: "allowlist", allowedTools: ["bash"], allowedCommandPatterns: ["^git status$"] });
assert.equal(decidePermission(request("git status"), { mode: "deny", allowedTools: [], allowedCommandPatterns: [] }).outcome.outcome, "selected");
const allowed = decidePermission(request("git status"), { mode: "allowlist", allowedTools: ["bash"], allowedCommandPatterns: ["^git status$"] });
assert.deepEqual(allowed.outcome, { outcome: "selected", optionId: "allow" });
const missingDetail = decidePermission(request(undefined), { permissionMode: "allowlist", allowedTools: ["bash"], allowedCommandPatterns: ["^git status$"] });
const objectInput = decidePermission(request({ command: "git status", timeout: 60 }), { mode: "allowlist", allowedTools: ["bash"], allowedCommandPatterns: ["^git status$"] });
assert.deepEqual(objectInput.outcome, { outcome: "selected", optionId: "allow" });
const missingDetail = decidePermission(request(undefined), { mode: "allowlist", allowedTools: ["bash"], allowedCommandPatterns: ["^git status$"] });
assert.deepEqual(missingDetail.outcome, { outcome: "selected", optionId: "reject" });
const destructive = decidePermission(request("rm -rf /"), { permissionMode: "allowlist", allowedTools: ["bash"], allowedCommandPatterns: ["^git status$"] });
const destructive = decidePermission(request("rm -rf /"), { mode: "allowlist", allowedTools: ["bash"], allowedCommandPatterns: ["^git status$"] });
assert.deepEqual(destructive.outcome, { outcome: "selected", optionId: "reject" });
const unanchored = { mode: "allowlist" as const, allowedTools: ["bash"], allowedCommandPatterns: ["git status"] };
assert.deepEqual(decidePermission(request("git status"), unanchored).outcome, { outcome: "selected", optionId: "allow" });
assert.deepEqual(decidePermission(request("git status; rm -rf /"), unanchored).outcome, { outcome: "selected", optionId: "reject" });
assert.deepEqual(decidePermission(request("git status", { name: "python", title: "bash git status" }), unanchored).outcome, { outcome: "selected", optionId: "reject" });
assert.deepEqual(decidePermission(request("git status", { name: undefined, title: "bash git status" }), unanchored).outcome, { outcome: "selected", optionId: "reject" });
assert.deepEqual(decidePermission(request({ command: "git status", env: { PATH: "/tmp" } }), unanchored).outcome, { outcome: "selected", optionId: "reject" });
});
+52 -13
View File
@@ -3,27 +3,31 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { AcpBackendRegistry } from "../src/acp/backend-registry.js";
import { AcpSessionManager } from "../src/acp/session-manager.js";
import { parseConfig } from "../src/config.js";
import { DurableSessionStore } from "../src/core/durable-session-store.js";
import { RoleRegistry } from "../src/roles/role-registry.js";
import { BotProfileResolver } from "../src/roles/role-registry.js";
const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
function config(stateFile: string, logFile: string) {
function config(stateFile: string, logFile: string, persona = "test", agentEnv: Record<string, string> = {}) {
return parseConfig({
configVersion: 2, acp: { stateFile, promptTimeoutMs: 2_000, cancelGraceMs: 100, idleTimeoutMs: 100, sweepIntervalMs: 20, maxProcesses: 2 },
backends: [{ id: "kimi", command: process.execPath, args: [fixture], env: { FAKE_ACP_LOG: logFile } }],
skills: [], defaultRole: "assistant",
roles: [{ id: "assistant", backend: "kimi", workspace: path.resolve("."), persona: "test", skills: [], policy: { permissionMode: "deny" } }],
platforms: {}
configVersion: 3,
bot: {
id: "test-bot", workspace: path.resolve("."), persona,
agent: { id: "fake", command: process.execPath, args: [fixture], env: { FAKE_ACP_LOG: logFile, ...agentEnv } },
skills: [], permissions: { mode: "deny" }
},
gateway: { platform: { type: "qq", appId: "id", clientSecret: "secret", botNames: ["bot"] } },
runtime: { acp: { stateFile, promptTimeoutMs: 2_000, cancelGraceMs: 100, idleTimeoutMs: 100, sweepIntervalMs: 20, maxProcesses: 2 } }
});
}
async function runtime(stateFile: string, logFile: string) {
const cfg = config(stateFile, logFile); const store = new DurableSessionStore(stateFile); await store.open();
return { cfg, store, manager: new AcpSessionManager(cfg.acp, new AcpBackendRegistry(cfg.backends), new RoleRegistry(cfg), store) };
async function runtime(stateFile: string, logFile: string, persona = "test", agentEnv: Record<string, string> = {}) {
const cfg = config(stateFile, logFile, persona, agentEnv);
const store = new DurableSessionStore(stateFile, { botId: cfg.bot.id, platform: cfg.gateway.platform.type }); await store.open();
const bot = new BotProfileResolver(cfg).bot;
return { cfg, store, bot, manager: new AcpSessionManager(cfg.runtime.acp, bot, store) };
}
test("creates, persists, idles, and resumes the same native session", async () => {
@@ -31,7 +35,7 @@ test("creates, persists, idles, and resumes the same native session", async () =
const first = await runtime(state, log);
const response = await first.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "one" });
const sessionId = response.text.split(":")[1];
assert.match(response.text, /reply:fake-/);
assert.equal(response.botId, "test-bot"); assert.match(response.text, /reply:fake-/);
await new Promise((resolve) => setTimeout(resolve, 180));
await first.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "two" });
await first.manager.shutdown(); await first.store.close();
@@ -44,7 +48,33 @@ test("creates, persists, idles, and resumes the same native session", async () =
await second.manager.shutdown(); await second.store.close();
});
test("cancel reaches a hanging ACP prompt and new unbinds", async () => {
test("resume falls back to load and failed restore creates a new session", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-acp-restore-"));
const state = path.join(dir, "state.json"); const log = path.join(dir, "fake.log");
const resumeFail = path.join(dir, "resume-fail"); const loadFail = path.join(dir, "load-fail");
const agentEnv = { FAKE_ACP_RESUME_FAIL_FILE: resumeFail, FAKE_ACP_LOAD_FAIL_FILE: loadFail };
const first = await runtime(state, log, "test", agentEnv);
const initial = await first.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "one" });
const initialSession = initial.text.split(":")[1];
await first.manager.shutdown(); await first.store.close();
await fs.promises.writeFile(resumeFail, "1");
const fallback = await runtime(state, log, "test", agentEnv);
const loaded = await fallback.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "two" });
assert.equal(loaded.text, `reply:${initialSession}:two`);
await fallback.manager.shutdown(); await fallback.store.close();
await fs.promises.writeFile(loadFail, "1");
const replacement = await runtime(state, log, "test", agentEnv);
const fresh = await replacement.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "three" });
assert.notEqual(fresh.text.split(":")[1], initialSession);
await replacement.manager.shutdown(); await replacement.store.close();
const entries = (await fs.promises.readFile(log, "utf8")).trim().split("\n").map(JSON.parse);
assert.ok(entries.some((entry) => entry.method === "session/load" && entry.sessionId === initialSession));
});
test("cancel reaches hanging prompt, reset unbinds, and fingerprint changes session", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-acp-cancel-")); const state = path.join(dir, "state.json"); const log = path.join(dir, "fake.log");
const current = await runtime(state, log);
await current.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "ready" });
@@ -55,4 +85,13 @@ test("cancel reaches a hanging ACP prompt and new unbinds", async () => {
await current.manager.reset("qq", "chat");
assert.equal(current.store.stats().bindings, 0);
await current.manager.shutdown(); await current.store.close();
const original = await runtime(state, log, "one");
const first = await original.manager.prompt({ platform: "qq", chatId: "other", userId: "user", text: "first" });
const firstSession = first.text.split(":")[1];
await original.manager.shutdown(); await original.store.close();
const changed = await runtime(state, log, "two");
const second = await changed.manager.prompt({ platform: "qq", chatId: "other", userId: "user", text: "second" });
assert.notEqual(second.text.split(":")[1], firstSession);
await changed.manager.shutdown(); await changed.store.close();
});
+6 -8
View File
@@ -3,21 +3,19 @@ import path from "node:path";
import test from "node:test";
import { AcpWorker } from "../src/acp/worker.js";
import { parseConfig } from "../src/config.js";
import { RoleRegistry } from "../src/roles/role-registry.js";
import { BotProfileResolver } from "../src/roles/role-registry.js";
const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
function makeWorker(promptTimeoutMs = 80) {
const config = parseConfig({
configVersion: 2,
acp: { promptTimeoutMs, cancelGraceMs: 30 },
backends: [{ id: "kimi", command: process.execPath, args: [fixture] }],
defaultRole: "assistant",
roles: [{ id: "assistant", backend: "kimi", workspace: path.resolve("."), policy: { permissionMode: "deny" } }],
platforms: {}
configVersion: 3,
bot: { id: "test-bot", workspace: path.resolve("."), persona: "", agent: { id: "fake", command: process.execPath, args: [fixture] }, permissions: { mode: "deny" } },
gateway: { platform: { type: "webhook", secret: "secret" } },
runtime: { acp: { promptTimeoutMs, cancelGraceMs: 30 } }
});
let crashes = 0;
const worker = new AcpWorker(config.backends[0], new RoleRegistry(config).get(), config.acp, () => { crashes++; });
const worker = new AcpWorker(new BotProfileResolver(config).bot, config.runtime.acp, () => { crashes++; });
return { worker, crashes: () => crashes };
}
+44
View File
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { parseConfig } from "../src/config.js";
import { assertOperationalConfig, loadConfigFile, writeConfigFile } from "../src/cli/config-file.js";
const config = parseConfig({
configVersion: 3,
bot: { id: "file-bot", workspace: "/tmp", persona: "", agent: { id: "node", command: process.execPath, args: ["acp"] }, permissions: { mode: "deny" } },
gateway: { platform: { type: "webhook", secret: "secret" } },
runtime: {}
});
test("runtime config loading fails closed instead of seeding example", () => {
const missing = path.join(os.tmpdir(), `gori-missing-${Date.now()}`, "config.json");
assert.throws(() => loadConfigFile(missing), /documentation only/);
});
test("config writes are atomic and mode 0600", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-config-write-"));
const file = path.join(dir, "config.json");
writeConfigFile(file, config);
assert.equal((await fs.promises.stat(dir)).mode & 0o777, 0o700);
assert.equal((await fs.promises.stat(file)).mode & 0o777, 0o600);
assert.equal(loadConfigFile(file).config.bot.id, "file-bot");
assert.equal((await fs.promises.readdir(dir)).some((name) => name.endsWith(".tmp")), false);
});
test("operational validation rejects placeholders for every credential family", () => {
assert.doesNotThrow(() => assertOperationalConfig(config));
const platforms = [
{ type: "qq", appId: "QQ_APP_ID", clientSecret: "QQ_CLIENT_SECRET", botNames: ["QQ_BOT_NAME"] },
{ type: "feishu", appId: "FEISHU_APP_ID", appSecret: "FEISHU_APP_SECRET" },
{ type: "wecom", corpId: "WECOM_CORP_ID", agentId: "WECOM_AGENT_ID", secret: "WECOM_SECRET" },
{ type: "webhook", secret: "WEBHOOK_SECRET" },
{ type: "weixin", secret: "WEIXIN_SECRET" }
];
for (const platform of platforms) {
const candidate = parseConfig({ ...config, gateway: { ...config.gateway, platform } });
assert.throws(() => assertOperationalConfig(candidate), /missing or placeholder/);
}
});
+59
View File
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
function runCli(args: string[], root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-cli-"))) {
return spawnSync(process.execPath, ["--import", "tsx", path.resolve("src/cli.ts"), ...args], {
cwd: path.resolve("."),
encoding: "utf8",
env: { ...process.env, GORI_AGENT_ROOT: root, GORI_GATEWAY_CONFIG: "" }
});
}
test("CLI help exposes only direct instance commands", () => {
const result = runCli(["--help"]);
assert.equal(result.status, 0);
for (const command of ["init", "setup", "start", "stop", "restart", "status", "logs", "doctor"]) {
assert.match(result.stdout, new RegExp(`gori-agent ${command} <bot-id>`));
}
assert.match(result.stdout, /gori-agent list/);
assert.doesNotMatch(result.stdout, /gori-agent instance|--config|--json|discover-backends|print feishu/);
});
test("CLI dispatches direct commands and rejects removed entry points", () => {
assert.equal(runCli(["list"]).status, 0);
for (const command of ["setup", "start", "stop", "restart", "status", "logs", "doctor"]) {
const result = runCli([command, "missing-bot"]);
assert.equal(result.status, 1);
assert.match(result.stderr, /Runtime config does not exist/);
}
const init = runCli(["init", "INVALID"]);
assert.equal(init.status, 1);
assert.match(init.stderr, /Invalid bot ID/);
for (const removed of [["instance", "list"], ["discover-backends"], ["discover-agents"], ["print", "feishu"]]) {
const result = runCli(removed);
assert.equal(result.status, 1);
assert.match(result.stderr, /Unknown command/);
}
});
test("CLI rejects options and invalid command argument counts", () => {
for (const args of [["status", "bot", "--config", "config.json"], ["list", "--json"], ["start", "--bogus"]]) {
const result = runCli(args);
assert.equal(result.status, 1);
assert.match(result.stderr, /Unknown option/);
}
const missing = runCli(["setup"]);
assert.equal(missing.status, 1);
assert.match(missing.stderr, /setup requires exactly one <bot-id>/);
const extra = runCli(["doctor", "one", "two"]);
assert.equal(extra.status, 1);
assert.match(extra.stderr, /doctor requires exactly one <bot-id>/);
});
+57 -21
View File
@@ -1,31 +1,67 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import { parseConfig } from "../src/config.js";
const platforms = {
qq: { enabled: true, appId: "id", clientSecret: "secret", connectionMode: "websocket" as const },
feishu: {}, wecom: {}, webhook: {}, weixin: {}
};
function raw(overrides: Record<string, unknown> = {}) {
return {
configVersion: 3,
bot: {
id: "test-bot", workspace: "/tmp", persona: "test",
agent: { id: "kimi", command: "kimi", args: ["acp"], env: {} },
skills: [], permissions: { mode: "deny", allowedTools: [], allowedCommandPatterns: [] }
},
gateway: { platform: { type: "webhook", secret: "secret" } },
runtime: {},
...overrides
};
}
test("migrates a v1 Kimi config and preserves QQ fields", () => {
const config = parseConfig({
server: { port: 8787 }, policy: {}, defaultAgent: "kimi",
agents: [{ name: "kimi", command: "/home/ubuntu/.kimi-code/bin/kimi", args: ["-p"], cwd: "/tmp" }], platforms
});
assert.equal(config.configVersion, 2);
assert.deepEqual(config.backends[0].args, ["acp"]);
assert.equal(config.roles[0].workspace, "/tmp");
assert.equal(config.platforms.qq.clientSecret, "secret");
assert.equal(config.platforms.qq.connectionMode, "websocket");
test("parses Config v3 defaults for one Bot, agent, and platform", () => {
const config = parseConfig(raw());
assert.equal(config.configVersion, 3);
assert.equal(config.bot.id, "test-bot");
assert.equal(config.bot.agent.id, "kimi");
assert.equal(config.gateway.platform.type, "webhook");
assert.equal(config.runtime.acp.promptTimeoutMs, 7_200_000);
assert.equal(config.bot.permissions.mode, "deny");
});
test("does not silently migrate a non-Kimi CLI agent", () => {
assert.throws(() => parseConfig({ defaultAgent: "echo", agents: [{ name: "echo", command: "node" }], platforms }), /only supports a Kimi/);
test("explicitly rejects non-v3 configuration and unknown fields", () => {
assert.throws(() => parseConfig({ configVersion: 2 }), /requires Config v3/);
assert.throws(() => parseConfig({ defaultAgent: "kimi" }), /requires Config v3/);
assert.throws(() => parseConfig(raw({ bot: { ...(raw().bot as object), legacyRole: "ops" } })), /Unrecognized key/);
assert.throws(() => parseConfig(raw({ gateway: { platform: { type: "webhook", secret: "secret", enabled: true } } })), /Unrecognized key/);
});
test("validates role references and absolute workspace", () => {
assert.throws(() => parseConfig({
configVersion: 2, backends: [{ id: "kimi", command: "kimi", args: ["acp"] }], defaultRole: "a",
roles: [{ id: "a", backend: "missing", workspace: "relative" }], platforms
}), /workspace must be absolute/);
test("validates bot ID, absolute workspace, skills, and command regex", () => {
assert.throws(() => parseConfig(raw({ bot: { ...(raw().bot as object), id: "../bad" } })), /bot\.id/);
assert.throws(() => parseConfig(raw({ bot: { ...(raw().bot as object), workspace: "relative" } })), /workspace must be absolute/);
assert.throws(() => parseConfig(raw({ bot: { ...(raw().bot as object), skills: [{ id: "x", file: "relative" }] } })), /file must be absolute/);
assert.throws(() => parseConfig(raw({ bot: { ...(raw().bot as object), permissions: { mode: "allowlist", allowedTools: ["bash"], allowedCommandPatterns: ["["] } } })), /invalid command pattern/);
});
test("validates gateway server host and port", () => {
for (const host of ["localhost", "0.0.0.0", "::1", "gateway.example.com"]) {
assert.equal(parseConfig(raw({ gateway: { server: { host, port: 8787 }, platform: { type: "webhook", secret: "secret" } } })).gateway.server.host, host);
}
for (const host of ["http://localhost", "localhost:8787", "[::1]", "bad host", "host/path"]) {
assert.throws(() => parseConfig(raw({ gateway: { server: { host, port: 8787 }, platform: { type: "webhook", secret: "secret" } } })), /server host/);
}
for (const port of [0, 65_536, 1.5]) {
assert.throws(() => parseConfig(raw({ gateway: { server: { host: "localhost", port }, platform: { type: "webhook", secret: "secret" } } })), /gateway/);
}
});
test("config.example.json is a parseable, secret-free documentation template", () => {
const text = fs.readFileSync(path.resolve("config.example.json"), "utf8");
const config = parseConfig(JSON.parse(text) as unknown);
assert.equal(config.configVersion, 3);
assert.equal(config.bot.id, "BOT_ID");
assert.equal(config.bot.permissions.mode, "deny");
assert.equal(config.runtime.acp.promptTimeoutMs, 7_200_000);
assert.equal(config.gateway.platform.type, "qq");
assert.match(text, /QQ_CLIENT_SECRET/);
assert.doesNotMatch(text, /configVersion"\s*:\s*[12]/);
});
+39 -18
View File
@@ -5,37 +5,58 @@ import path from "node:path";
import test from "node:test";
import { DurableSessionStore } from "../src/core/durable-session-store.js";
test("persists chat role and native session across reopen", async () => {
const identity = { botId: "test-bot", platform: "qq" };
test("persists state v2 binding across reopen with 0600 atomic file", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-store-"));
const file = path.join(dir, "state.json");
const first = new DurableSessionStore(file);
const first = new DurableSessionStore(file, identity);
await first.open();
await first.setSelectedRole("qq:chat", "ops");
await first.setBinding({ chatKey: "qq:chat", roleId: "ops", backendId: "kimi", nativeSessionId: "native-1", workspace: "/tmp", roleFingerprint: "fp", createdAt: 1, updatedAt: 1 });
await first.setBinding({ chatKey: "qq:chat", agentId: "kimi", nativeSessionId: "native-1", workspace: "/tmp", botFingerprint: "fp", createdAt: 1, updatedAt: 1 });
await first.close();
assert.equal((await fs.promises.readdir(dir)).some((name) => name.endsWith(".tmp")), false);
assert.equal((await fs.promises.stat(file)).mode & 0o777, 0o600);
const persisted = JSON.parse(await fs.promises.readFile(file, "utf8"));
assert.equal(persisted.version, 2); assert.equal(persisted.botId, "test-bot"); assert.equal(persisted.platform, "qq");
const second = new DurableSessionStore(file);
const second = new DurableSessionStore(file, identity);
await second.open();
assert.equal(second.getSelectedRole("qq:chat", "assistant"), "ops");
assert.equal(second.getBinding("qq:chat", "ops")?.nativeSessionId, "native-1");
assert.equal(second.getBinding("qq:chat")?.nativeSessionId, "native-1");
await second.close();
});
test("preserves corrupt state and fails explicitly", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-store-bad-"));
test("rejects old state and bot or platform identity mismatch without rewriting", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-store-id-"));
const file = path.join(dir, "state.json");
await fs.promises.writeFile(file, "not-json");
const store = new DurableSessionStore(file);
await assert.rejects(store.open(), /original file was preserved/);
assert.equal(await fs.promises.readFile(file, "utf8"), "not-json");
const old = '{"version":1,"bindings":{}}\n';
await fs.promises.writeFile(file, old);
await assert.rejects(new DurableSessionStore(file, identity).open(), /requires state v2/);
assert.equal(await fs.promises.readFile(file, "utf8"), old);
await fs.promises.writeFile(file, JSON.stringify({ version: 2, botId: "other", platform: "qq", bindings: {} }));
await assert.rejects(new DurableSessionStore(file, identity).open(), /identity mismatch/);
await fs.promises.writeFile(file, JSON.stringify({ version: 2, botId: "test-bot", platform: "feishu", bindings: {} }));
await assert.rejects(new DurableSessionStore(file, identity).open(), /identity mismatch/);
await fs.promises.writeFile(file, JSON.stringify({ version: 2, botId: "test-bot", platform: "qq", bindings: { "qq:chat": { chatKey: "qq:other" } } }));
await assert.rejects(new DurableSessionStore(file, identity).open(), /invalid state v2 binding/);
});
test("refuses a second writer lock", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-store-lock-"));
test("preserves corrupt state and refuses a second writer lock", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-store-bad-"));
const badFile = path.join(dir, "bad.json");
await fs.promises.writeFile(badFile, "not-json");
await assert.rejects(new DurableSessionStore(badFile, identity).open(), /original file was preserved/);
assert.equal(await fs.promises.readFile(badFile, "utf8"), "not-json");
const file = path.join(dir, "state.json");
const first = new DurableSessionStore(file); await first.open();
const second = new DurableSessionStore(file);
await assert.rejects(second.open(), /locked by another/);
const first = new DurableSessionStore(file, identity); await first.open();
await assert.rejects(new DurableSessionStore(file, identity).open(), /locked by another/);
await first.close();
await fs.promises.writeFile(`${file}.lock`, "2147483647\n", { mode: 0o600 });
const recovered = new DurableSessionStore(file, identity);
await recovered.open();
await recovered.close();
assert.equal(fs.existsSync(`${file}.lock`), false);
});
+10 -2
View File
@@ -21,8 +21,16 @@ const app = acp.agent({ name: "fake-acp-agent" })
log({ method: "session/new", sessionId, cwd: params.cwd });
return { sessionId };
})
.onRequest(acp.methods.agent.session.load, ({ params }) => { log({ method: "session/load", sessionId: params.sessionId }); return {}; })
.onRequest(acp.methods.agent.session.resume, ({ params }) => { log({ method: "session/resume", sessionId: params.sessionId }); return {}; })
.onRequest(acp.methods.agent.session.load, ({ params }) => {
log({ method: "session/load", sessionId: params.sessionId });
if (process.env.FAKE_ACP_LOAD_FAIL === "1" || (process.env.FAKE_ACP_LOAD_FAIL_FILE && fs.existsSync(process.env.FAKE_ACP_LOAD_FAIL_FILE))) throw new Error("load failed");
return {};
})
.onRequest(acp.methods.agent.session.resume, ({ params }) => {
log({ method: "session/resume", sessionId: params.sessionId });
if (process.env.FAKE_ACP_RESUME_FAIL === "1" || (process.env.FAKE_ACP_RESUME_FAIL_FILE && fs.existsSync(process.env.FAKE_ACP_RESUME_FAIL_FILE))) throw new Error("resume failed");
return {};
})
.onRequest(acp.methods.agent.session.close, ({ params }) => { log({ method: "session/close", sessionId: params.sessionId }); return {}; })
.onRequest(acp.methods.agent.session.list, () => ({ sessions: [] }))
.onRequest(acp.methods.agent.session.prompt, async ({ params, client, signal }) => {
+12 -16
View File
@@ -1,32 +1,26 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ConversationRuntime } from "../src/acp/types.js";
import { parseConfig } from "../src/config.js";
import type { PlatformAdapter } from "../src/core/adapter.js";
import { Gateway } from "../src/core/gateway.js";
import type { IncomingMessage } from "../src/core/types.js";
import { RoleRegistry } from "../src/roles/role-registry.js";
class FakeRuntime implements ConversationRuntime {
role = "assistant"; prompts = 0; cancelled = 0; resets = 0; release?: () => void;
async prompt() { this.prompts++; await new Promise<void>((resolve) => { this.release = resolve; }); return { text: "done", roleId: this.role, backendId: "kimi" }; }
prompts = 0; cancelled = 0; resets = 0; release?: () => void;
async prompt() { this.prompts++; await new Promise<void>((resolve) => { this.release = resolve; }); return { text: "done", botId: "test-bot", agentId: "kimi" }; }
async cancel() { this.cancelled++; this.release?.(); return true; }
async reset() { this.resets++; }
async selectRole(_p: string, _c: string, role: string) { this.role = role; }
selectedRole() { return this.role; }
status() { return { role: this.role, running: Boolean(this.release) }; }
status() { return { bot: "test-bot", agent: "kimi", workspace: "/tmp", running: Boolean(this.release) }; }
stats() { return { activeWorkers: 0, inFlight: 0, crashes: 0, persistedBindings: 0 }; }
async shutdown() {}
}
const cfg = parseConfig({ configVersion: 2, backends: [{ id: "kimi", command: "kimi", args: ["acp"] }], defaultRole: "assistant", roles: [
{ id: "assistant", backend: "kimi", workspace: "/tmp" }, { id: "ops", backend: "kimi", workspace: "/tmp" }
], platforms: {} });
const policy = { allowedUsers: [] as string[], allowedChats: [] as string[], requireMentionInGroup: false };
const adapter: PlatformAdapter = { name: "test", async handleWebhook() { return {}; }, async sendMessage() {} };
const message = (text: string): IncomingMessage => ({ platform: "qq", chatId: "chat", userId: "user", text });
test("cancel bypasses the chat lock and new resets", async () => {
const runtime = new FakeRuntime(); const gateway = new Gateway(cfg.policy, runtime, new RoleRegistry(cfg));
const runtime = new FakeRuntime(); const gateway = new Gateway(policy, runtime);
const turn = gateway.receive(message("work"), adapter, { synchronous: true });
await new Promise((resolve) => setTimeout(resolve, 10));
const cancelled = await gateway.receive(message("/cancel"), adapter, { synchronous: true });
@@ -37,9 +31,11 @@ test("cancel bypasses the chat lock and new resets", async () => {
assert.equal(runtime.resets, 1);
});
test("role aliases, role selection, and status are routed", async () => {
const runtime = new FakeRuntime(); const gateway = new Gateway(cfg.policy, runtime, new RoleRegistry(cfg));
assert.match((await gateway.receive(message("/agents"), adapter, { synchronous: true })).reply || "", /Deprecated alias/);
assert.equal((await gateway.receive(message("/role ops"), adapter, { synchronous: true })).reply, "Selected role: ops");
assert.match((await gateway.receive(message("/status"), adapter, { synchronous: true })).reply || "", /role=ops/);
test("fixed Bot status and retired role commands never reach ACP", async () => {
const runtime = new FakeRuntime(); const gateway = new Gateway(policy, runtime);
for (const command of ["/roles", "/role ops", "/agents", "/agent ops"]) {
assert.match((await gateway.receive(message(command), adapter, { synchronous: true })).reply || "", /removed in Config v3/);
}
assert.match((await gateway.receive(message("/status"), adapter, { synchronous: true })).reply || "", /bot=test-bot/);
assert.equal(runtime.prompts, 0);
});
+109
View File
@@ -0,0 +1,109 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import { EventEmitter } from "node:events";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { writeConfigFile } from "../src/cli/config-file.js";
import { assertSetupPidSafe, instanceDirectory, instanceRunnerPath, matchesInstanceRunner, runInstanceCommand } from "../src/cli/instance.js";
import { runInstanceRunner, waitForShutdown } from "../src/cli/instance-runner.js";
import { parseConfig } from "../src/config.js";
test("instance paths reject traversal and config identity mismatch", async () => {
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-instances-"));
const previous = process.env.GORI_AGENT_ROOT;
process.env.GORI_AGENT_ROOT = root;
try {
assert.throws(() => instanceDirectory("../escape"), /Invalid bot ID/);
assert.throws(() => instanceDirectory("Uppercase"), /Invalid bot ID/);
await assert.rejects(runInstanceCommand("list", ["extra"]), /does not accept/);
await assert.rejects(runInstanceCommand("status", ["one", "two"]), /exactly one/);
await assert.rejects(runInstanceCommand("unknown", ["bot"]), /Unknown command/);
await assert.rejects(runInstanceCommand("status", ["missing-bot"]), /Runtime config does not exist/);
const directory = instanceDirectory("directory-bot");
const config = parseConfig({
configVersion: 3,
bot: { id: "different-bot", workspace: "/tmp", persona: "", agent: { id: "kimi", command: "kimi", args: ["acp"] }, permissions: { mode: "deny" } },
gateway: { platform: { type: "webhook", secret: "secret" } },
runtime: {}
});
writeConfigFile(path.join(directory, "config.json"), config);
await assert.rejects(runInstanceCommand("status", ["directory-bot"]), /does not match config bot\.id/);
const privateDirectory = instanceDirectory("private-bot");
writeConfigFile(path.join(privateDirectory, "config.json"), parseConfig({
configVersion: 3,
bot: { id: "private-bot", workspace: "/tmp", persona: "", agent: { id: "kimi", command: "kimi", args: ["acp"] }, permissions: { mode: "deny" } },
gateway: { platform: { type: "webhook", secret: "secret" } },
runtime: {}
}));
await fs.promises.chmod(privateDirectory, 0o755);
await assert.rejects(runInstanceCommand("status", ["private-bot"]), /mode must be 0700/);
} finally {
if (previous === undefined) delete process.env.GORI_AGENT_ROOT;
else process.env.GORI_AGENT_ROOT = previous;
}
});
test("setup rejects both an owned runner PID and a foreign PID", () => {
assert.throws(() => assertSetupPidSafe("safe-bot", { kind: "running", pid: 123 }), /instance 'safe-bot' is running/);
assert.throws(() => assertSetupPidSafe("safe-bot", { kind: "foreign", pid: 456 }), /unrelated live process/);
assert.doesNotThrow(() => assertSetupPidSafe("safe-bot", { kind: "absent" }));
assert.doesNotThrow(() => assertSetupPidSafe("safe-bot", { kind: "stale" }));
});
test("runner identity requires exact executable, runner, and config arguments", () => {
const configFile = path.resolve("/tmp/test-instance-config.json");
const commandLine = [process.execPath, instanceRunnerPath(), configFile];
assert.equal(matchesInstanceRunner(commandLine, process.execPath, configFile), true);
assert.equal(matchesInstanceRunner([...commandLine, "extra"], process.execPath, configFile), false);
assert.equal(matchesInstanceRunner([process.execPath, path.resolve("dist/cli.js"), configFile], process.execPath, configFile), false);
assert.equal(matchesInstanceRunner(commandLine, "/bin/sh", configFile), false);
assert.equal(matchesInstanceRunner(commandLine, process.execPath, `${configFile}.other`), false);
});
test("instance runner rejects unsafe argument and config paths before starting a server", async () => {
await assert.rejects(runInstanceRunner([]), /exactly one config path/);
await assert.rejects(runInstanceRunner(["one", "two"]), /exactly one config path/);
await assert.rejects(runInstanceRunner([path.join(os.tmpdir(), `missing-runner-${Date.now()}.json`)]), /Runtime config does not exist/);
});
test("instance runner gracefully shuts down once on SIGTERM", async () => {
const signals = new EventEmitter();
let shutdowns = 0;
const result = waitForShutdown({ shutdown: async () => { shutdowns++; } }, signals);
signals.emit("SIGTERM");
signals.emit("SIGINT");
assert.equal(await result, 0);
assert.equal(shutdowns, 1);
assert.equal(signals.listenerCount("SIGINT"), 0);
assert.equal(signals.listenerCount("SIGTERM"), 0);
});
test("setup refuses a foreign live PID and unsafe PID file", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-setup-pid-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const previous = process.env.GORI_AGENT_ROOT;
process.env.GORI_AGENT_ROOT = root;
t.after(() => {
if (previous === undefined) delete process.env.GORI_AGENT_ROOT;
else process.env.GORI_AGENT_ROOT = previous;
});
const directory = instanceDirectory("safe-bot");
writeConfigFile(path.join(directory, "config.json"), parseConfig({
configVersion: 3,
bot: { id: "safe-bot", workspace: os.tmpdir(), persona: "", agent: { id: "node", command: process.execPath, args: ["acp"] }, permissions: { mode: "deny" } },
gateway: { platform: { type: "webhook", secret: "test-secret" } },
runtime: {}
}));
fs.mkdirSync(path.join(directory, "state"), { mode: 0o700 });
fs.mkdirSync(path.join(directory, "logs"), { mode: 0o700 });
const pidFile = path.join(directory, "state", "gori-agent.pid");
fs.writeFileSync(pidFile, `${process.pid}\n`, { mode: 0o600 });
await assert.rejects(runInstanceCommand("setup", ["safe-bot"]), /unrelated live process/);
fs.chmodSync(pidFile, 0o644);
await assert.rejects(runInstanceCommand("setup", ["safe-bot"]), /PID file mode must be 0600/);
});
+7 -6
View File
@@ -1,16 +1,17 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseConfig } from "../src/config.js";
import type { QqConfig } from "../src/config.js";
import { QqAdapter } from "../src/platforms/qq/adapter.js";
const config = parseConfig({ configVersion: 2, backends: [{ id: "kimi", command: "kimi", args: ["acp"] }], defaultRole: "assistant", roles: [{ id: "assistant", backend: "kimi", workspace: "/tmp" }], platforms: { qq: {
enabled: true, connectionMode: "webhook", appId: "id", clientSecret: "secret", verifySignature: false, botNames: ["Bot"]
} } });
const config: QqConfig = {
type: "qq", connectionMode: "webhook", appId: "id", clientSecret: "secret", botSecret: "", verifySignature: false,
botNames: ["Bot"], intents: 33_554_432, shard: [0, 1]
};
test("normalizes GROUP and C2C author openid while ACK remains immediate", async () => {
const received: any[] = [];
const gateway = { receive: async (message: unknown) => { received.push(message); throw new Error("ACP failed"); } };
const adapter = new QqAdapter(config.platforms.qq, gateway as never);
const adapter = new QqAdapter(config, gateway as never);
const group = await adapter.handleWebhook({ body: { op: 0, t: "GROUP_AT_MESSAGE_CREATE", d: { id: "m1", group_openid: "g1", author: { user_openid: "u1" }, content: "@Bot hi" } }, headers: {}, query: {}, req: {} as never });
const c2c = await adapter.handleWebhook({ body: { op: 0, t: "C2C_MESSAGE_CREATE", d: { id: "m2", author: { user_openid: "u2" }, content: "hello" } }, headers: {}, query: {}, req: {} as never });
assert.deepEqual(group.body, { op: 12 }); assert.deepEqual(c2c.body, { op: 12 });
@@ -27,7 +28,7 @@ test("sendMessage uses nested author.user_openid for C2C endpoint", async () =>
return new Response(JSON.stringify({ id: "sent" }), { status: 200 });
}) as typeof fetch;
try {
const adapter = new QqAdapter(config.platforms.qq, { receive: async () => ({ ok: true }) } as never);
const adapter = new QqAdapter(config, { receive: async () => ({ ok: true }) } as never);
await adapter.sendMessage({ target: { platform: "qq", chatId: "user:u2", raw: { author: { user_openid: "u2" } } }, text: "reply", replyTo: "m2" });
assert.ok(urls.some((url) => url.endsWith("/v2/users/u2/messages")));
} finally { globalThis.fetch = original; }
+82
View File
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import net from "node:net";
import path from "node:path";
import test from "node:test";
import { parseConfig } from "../src/config.js";
import { createGatewayRuntime, startServer } from "../src/server.js";
const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
test("server mounts only selected platform and exposes non-secret identity health", { concurrency: false }, async () => {
const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-server-"));
const previous = process.env.GORI_AGENT_HOME; process.env.GORI_AGENT_HOME = home;
const secret = "never-expose-this-secret";
const config = parseConfig({
configVersion: 3,
bot: { id: "route-bot", workspace: path.resolve("."), persona: "", agent: { id: "fake", command: process.execPath, args: [fixture] }, permissions: { mode: "deny" } },
gateway: { platform: { type: "webhook", secret } },
runtime: {}
});
const runtime = await createGatewayRuntime(config);
const server = runtime.app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
try {
const address = server.address(); assert.ok(address && typeof address !== "string");
const base = `http://127.0.0.1:${address.port}`;
const health = await (await fetch(`${base}/health`)).text();
assert.match(health, /"configVersion":3/); assert.match(health, /"botId":"route-bot"/); assert.match(health, /"platform":"webhook"/); assert.doesNotMatch(health, new RegExp(secret));
assert.equal((await fetch(`${base}/webhook/qq`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" })).status, 404);
assert.notEqual((await fetch(`${base}/webhook/generic`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" })).status, 404);
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
await runtime.shutdown();
if (previous === undefined) delete process.env.GORI_AGENT_HOME; else process.env.GORI_AGENT_HOME = previous;
}
});
test("QQ websocket mode does not mount the QQ webhook route", { concurrency: false }, async () => {
const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-server-qq-"));
const previous = process.env.GORI_AGENT_HOME; process.env.GORI_AGENT_HOME = home;
const config = parseConfig({
configVersion: 3,
bot: { id: "qq-bot", workspace: path.resolve("."), persona: "", agent: { id: "fake", command: process.execPath, args: [fixture] }, permissions: { mode: "deny" } },
gateway: { platform: { type: "qq", connectionMode: "websocket", appId: "id", clientSecret: "secret", botNames: ["Bot"] } },
runtime: {}
});
const runtime = await createGatewayRuntime(config);
const server = runtime.app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
try {
const address = server.address(); assert.ok(address && typeof address !== "string");
const response = await fetch(`http://127.0.0.1:${address.port}/webhook/qq`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
assert.equal(response.status, 404);
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
await runtime.shutdown();
if (previous === undefined) delete process.env.GORI_AGENT_HOME; else process.env.GORI_AGENT_HOME = previous;
}
});
test("listen failure releases the session state lock", { concurrency: false }, async () => {
const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-server-conflict-"));
const previous = process.env.GORI_AGENT_HOME; process.env.GORI_AGENT_HOME = home;
const occupied = net.createServer();
await new Promise<void>((resolve) => occupied.listen(0, "127.0.0.1", resolve));
const address = occupied.address(); assert.ok(address && typeof address !== "string");
const config = parseConfig({
configVersion: 3,
bot: { id: "conflict-bot", workspace: path.resolve("."), persona: "", agent: { id: "fake", command: process.execPath, args: [fixture] }, permissions: { mode: "deny" } },
gateway: { server: { host: "127.0.0.1", port: address.port }, platform: { type: "webhook", secret: "secret" } },
runtime: {}
});
try {
await assert.rejects(startServer(config), /EADDRINUSE/);
const runtime = await createGatewayRuntime(config);
await runtime.shutdown();
} finally {
await new Promise<void>((resolve, reject) => occupied.close((error) => error ? reject(error) : resolve()));
if (previous === undefined) delete process.env.GORI_AGENT_HOME; else process.env.GORI_AGENT_HOME = previous;
}
});
+82
View File
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
collectUsedInstancePorts,
parseServerHost,
parseServerPort,
suggestAvailablePort
} from "../src/cli/setup-server.js";
test("server host parser accepts bind addresses and hostnames", () => {
for (const host of ["0.0.0.0", "127.0.0.1", "::", "2001:db8::1", "localhost", "gateway.example.com"]) {
assert.equal(parseServerHost(host), host);
}
});
test("server host parser rejects URLs, ports, paths, whitespace, and invalid labels", () => {
for (const host of ["", "http://localhost", "localhost:8787", "[::1]", "host/path", "bad host", "-bad.example", "bad-.example"]) {
assert.throws(() => parseServerHost(host), /server host/);
}
});
test("server port parser accepts only decimal ports in range", () => {
assert.equal(parseServerPort("1"), 1);
assert.equal(parseServerPort("8787"), 8787);
assert.equal(parseServerPort("65535"), 65_535);
for (const port of ["", "0", "65536", "1.5", "0x20", "8e3", "-1"]) {
assert.throws(() => parseServerPort(port), /server port/);
}
});
test("available port suggestion advances without wrapping", () => {
assert.equal(suggestAvailablePort(8787, new Set([8787, 8788])), 8789);
assert.throws(() => suggestAvailablePort(65_535, new Set([65_535])), /No unassigned instance port/);
});
test("instance port scan reads only valid Config v3 regular files and excludes target", (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-setup-ports-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const writePeer = (name: string, value: unknown): string => {
const directory = path.join(root, name);
fs.mkdirSync(directory);
const file = path.join(directory, "config.json");
fs.writeFileSync(file, JSON.stringify(value));
return file;
};
const peer = (port: number, configVersion: number = 3) => ({
configVersion,
bot: {
id: "peer-bot",
workspace: os.tmpdir(),
persona: "peer",
agent: { id: "test", command: process.execPath, args: [], env: {} },
skills: [],
permissions: { mode: "deny", allowedTools: [], allowedCommandPatterns: [] }
},
gateway: {
server: { host: "127.0.0.1", port, publicBaseUrl: "" },
policy: { allowedUsers: [], allowedChats: [], requireMentionInGroup: true },
platform: { type: "webhook", secret: "test-secret" }
},
runtime: {}
});
writePeer("valid", peer(8787));
const excluded = writePeer("current", peer(8788));
writePeer("legacy", peer(8789, 2));
writePeer("out-of-range", peer(70_000));
writePeer("broken", "not an object");
fs.writeFileSync(path.join(root, "broken", "config.json"), "{broken");
const linkedDirectory = path.join(root, "linked");
fs.symlinkSync(path.join(root, "valid"), linkedDirectory, "dir");
const symlinkConfigDirectory = path.join(root, "symlink-config");
fs.mkdirSync(symlinkConfigDirectory);
fs.symlinkSync(path.join(root, "valid", "config.json"), path.join(symlinkConfigDirectory, "config.json"));
assert.deepEqual([...collectUsedInstancePorts(root, excluded)], [8787]);
});
+241
View File
@@ -0,0 +1,241 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import type { DiscoveredBackend } from "../src/acp/discovery.js";
import { loadConfigFile, writeConfigFile } from "../src/cli/config-file.js";
import type { Choice, PromptSession } from "../src/cli/prompt.js";
import { runSetup } from "../src/cli/setup.js";
import { parseConfig } from "../src/config.js";
const testSecret = "test-webhook-secret";
const readyAgent: DiscoveredBackend = {
id: "test-agent",
command: process.execPath,
args: ["test/fixtures/fake-acp-agent.mjs"],
status: "ready"
};
class FakePrompt implements PromptSession {
readonly defaults = new Map<string, string | undefined>();
closeCount = 0;
constructor(
private readonly answers: Record<string, string[]> = {},
private readonly secretAnswer = testSecret
) {}
async ask(question: string, defaultValue?: string): Promise<string> {
this.defaults.set(question, defaultValue);
const queued = this.answers[question];
return queued && queued.length > 0 ? queued.shift()! : defaultValue || "";
}
async askSecret(): Promise<string> { return this.secretAnswer; }
async askBoolean(_question: string, defaultValue = false): Promise<boolean> { return defaultValue; }
async askList(_question: string, defaultValues: string[] = []): Promise<string[]> { return defaultValues; }
async choose<T>(_question: string, choices: Choice<T>[], defaultIndex = 0): Promise<T> {
return (choices.find((choice) => choice.label === "Generic webhook") || choices[defaultIndex] || choices[0]).value;
}
close(): void { this.closeCount++; }
}
function peerConfig(port: number): unknown {
return {
configVersion: 3,
bot: {
id: "peer-bot",
workspace: os.tmpdir(),
persona: "peer",
agent: { id: readyAgent.id, command: readyAgent.command, args: readyAgent.args, env: {} },
skills: [],
permissions: { mode: "deny", allowedTools: [], allowedCommandPatterns: [] }
},
gateway: {
server: { host: "127.0.0.1", port, publicBaseUrl: "" },
policy: { allowedUsers: [], allowedChats: [], requireMentionInGroup: true },
platform: { type: "webhook", secret: "peer-test-secret" }
},
runtime: {}
};
}
function existingConfig(host = "0.0.0.0", port = 8787) {
return parseConfig({
configVersion: 3,
bot: {
id: "existing-bot",
workspace: os.tmpdir(),
persona: "existing persona",
agent: {
id: "custom-kimi",
command: readyAgent.command,
args: ["-m", "gori-gpt/gpt-5.5", "acp"],
env: { TEST_AGENT_SETTING: "preserved" }
},
skills: [{ id: "test-skill", file: path.join(os.tmpdir(), "test-skill.md"), maxBytes: 1234 }],
permissions: { mode: "allowlist", allowedTools: ["Read"], allowedCommandPatterns: ["^true$"] }
},
gateway: {
server: { host, port, publicBaseUrl: "https://public.example.test" },
policy: { allowedUsers: ["allowed-user"], allowedChats: ["allowed-chat"], requireMentionInGroup: false },
platform: { type: "webhook", secret: testSecret }
},
runtime: { acp: { promptTimeoutMs: 123456, maxProcesses: 3 } }
});
}
test("new setup suggests the next unassigned instance port", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-setup-new-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
for (const [name, port] of [["peer-one", 8787], ["peer-two", 8788]] as const) {
const directory = path.join(root, name);
fs.mkdirSync(directory);
fs.writeFileSync(path.join(directory, "config.json"), JSON.stringify(peerConfig(port)));
}
const target = path.join(root, "new-bot", "config.json");
const prompt = new FakePrompt();
const messages: string[] = [];
await runSetup(target, {
botId: "new-bot",
requireNew: true,
writeWithoutConfirmation: true,
prompt,
discoverAgents: async () => [readyAgent],
instancesDirectory: root,
log: (message) => messages.push(message)
});
const config = loadConfigFile(target).config;
assert.equal(prompt.defaults.get("Gateway server port"), "8789");
assert.equal(config.gateway.server.port, 8789);
assert.equal(config.gateway.server.host, "0.0.0.0");
assert.equal(fs.statSync(target).mode & 0o777, 0o600);
assert.equal(prompt.closeCount, 1);
assert.ok(messages.some((message) => message.includes("suggested port: 8789")));
assert.ok(messages.every((message) => !message.includes(testSecret)));
});
test("existing setup can change host and port while preserving public URL and secret", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-setup-existing-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const target = path.join(root, "existing-bot", "config.json");
writeConfigFile(target, existingConfig());
const peerDirectory = path.join(root, "peer");
fs.mkdirSync(peerDirectory);
fs.writeFileSync(path.join(peerDirectory, "config.json"), JSON.stringify(peerConfig(8787)));
const prompt = new FakePrompt({
"Gateway server host": ["http://bad-host", "127.0.0.1"],
"Gateway server port": ["not-a-port", "9001"]
}, "");
const messages: string[] = [];
await runSetup(target, {
botId: "existing-bot",
writeWithoutConfirmation: true,
prompt,
discoverAgents: async () => [readyAgent],
instancesDirectory: root,
log: (message) => messages.push(message)
});
const config = loadConfigFile(target).config;
assert.equal(prompt.defaults.get("Gateway server port"), "8787");
assert.equal(config.gateway.server.host, "127.0.0.1");
assert.equal(config.gateway.server.port, 9001);
assert.equal(config.gateway.server.publicBaseUrl, "https://public.example.test");
assert.equal(config.gateway.platform.type, "webhook");
assert.equal(config.gateway.platform.secret, testSecret);
assert.deepEqual(config.bot.agent, {
id: "custom-kimi",
command: readyAgent.command,
args: ["-m", "gori-gpt/gpt-5.5", "acp"],
env: { TEST_AGENT_SETTING: "preserved" }
});
assert.deepEqual(config.bot.skills, [{ id: "test-skill", file: path.join(os.tmpdir(), "test-skill.md"), maxBytes: 1234 }]);
assert.deepEqual(config.bot.permissions, { mode: "allowlist", allowedTools: ["Read"], allowedCommandPatterns: ["^true$"] });
assert.deepEqual(config.gateway.policy, { allowedUsers: ["allowed-user"], allowedChats: ["allowed-chat"], requireMentionInGroup: false });
assert.equal(config.runtime.acp.promptTimeoutMs, 123456);
assert.equal(config.runtime.acp.maxProcesses, 3);
assert.equal(prompt.closeCount, 1);
assert.ok(messages.some((message) => message.startsWith("ERROR: server host")));
assert.ok(messages.some((message) => message.startsWith("ERROR: server port")));
assert.ok(messages.some((message) => message.includes("suggested port: 8788")));
assert.ok(messages.every((message) => !message.includes(testSecret)));
});
test("existing setup keeps host and port when defaults are accepted", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-setup-keep-server-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const target = path.join(root, "existing-bot", "config.json");
writeConfigFile(target, existingConfig("localhost", 9123));
const prompt = new FakePrompt({}, "");
await runSetup(target, {
botId: "existing-bot",
writeWithoutConfirmation: true,
prompt,
discoverAgents: async () => [readyAgent],
instancesDirectory: root,
log: () => undefined
});
const config = loadConfigFile(target).config;
assert.equal(prompt.defaults.get("Gateway server host"), "localhost");
assert.equal(prompt.defaults.get("Gateway server port"), "9123");
assert.equal(config.gateway.server.host, "localhost");
assert.equal(config.gateway.server.port, 9123);
assert.equal(prompt.closeCount, 1);
});
test("existing setup succeeds without a ready discovered agent and preserves its agent", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-setup-no-ready-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const target = path.join(root, "existing-bot", "config.json");
writeConfigFile(target, existingConfig());
await runSetup(target, {
botId: "existing-bot",
writeWithoutConfirmation: true,
prompt: new FakePrompt({}, ""),
discoverAgents: async () => [{ id: "kimi", command: "kimi", args: ["acp"], status: "not-found" }],
instancesDirectory: root,
log: () => undefined
});
assert.deepEqual(loadConfigFile(target).config.bot.agent, existingConfig().bot.agent);
});
test("new setup still fails when no ACP agent is ready", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-setup-new-no-ready-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const prompt = new FakePrompt();
await assert.rejects(runSetup(path.join(root, "new-bot", "config.json"), {
botId: "new-bot",
requireNew: true,
writeWithoutConfirmation: true,
prompt,
discoverAgents: async () => [],
instancesDirectory: root,
log: () => undefined
}), /No ACP agent is available/);
assert.equal(prompt.closeCount, 1);
});
test("existing setup rejects attempts to change bot identity", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-setup-identity-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const target = path.join(root, "existing-bot", "config.json");
writeConfigFile(target, existingConfig());
await assert.rejects(runSetup(target, {
botId: "different-bot",
prompt: new FakePrompt(),
discoverAgents: async () => []
}), /Cannot change existing bot\.id/);
});