Refactor runtime around isolated bot instances
This commit is contained in:
@@ -1,353 +1,262 @@
|
||||
# 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。
|
||||
- `instance init` / `setup` 必须交互询问并校验 server host/port;扫描其他 Config v3 的声明端口,冲突时警告,新实例建议下一个未声明端口,已有配置不得静默改端口。
|
||||
- setup 的声明冲突提示不替代实际 bind 检查;`start` 对缺配置、ID 不匹配、错误权限、占位符、运行 PID、端口冲突 fail closed。
|
||||
|
||||
日常入口:
|
||||
|
||||
```bash
|
||||
cp config.example.json config.json
|
||||
chmod 600 config.json
|
||||
gori-agent instance init <bot-id>
|
||||
gori-agent instance start <bot-id>
|
||||
gori-agent instance stop <bot-id>
|
||||
gori-agent instance restart <bot-id>
|
||||
gori-agent instance status <bot-id>
|
||||
gori-agent instance logs <bot-id>
|
||||
gori-agent instance list
|
||||
gori-agent instance doctor <bot-id>
|
||||
```
|
||||
|
||||
`config.json` 和备份包含 IM credentials,不得提交。
|
||||
保留 `start --config`、`status --config`、`doctor --config` 等用于本地调试,但运行配置不存在时不得回退 example。
|
||||
|
||||
### 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/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. 使用 `instance init` 或 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. 运行 `instance doctor`,不发送真实平台消息。
|
||||
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。实例默认在仓库外。安全性按内容判断,不能只依赖文件名。
|
||||
|
||||
@@ -1,348 +1,256 @@
|
||||
# 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 也不会自动改写。
|
||||
|
||||
```bash
|
||||
kimi --version
|
||||
kimi doctor
|
||||
kimi acp --help
|
||||
```
|
||||
|
||||
ACP 使用 `~/.kimi-code/config.toml` 中的 `default_model`。模型切换应先在 Kimi Code 中完成,再重启 gori-agent;已有聊天要使用新模型时,在 QQ 中发送 `/new` 创建新 session。
|
||||
|
||||
### 2. 构建并迁移配置
|
||||
|
||||
```bash
|
||||
cd /home/ubuntu/gori-space/gori-agent
|
||||
npm install
|
||||
npm run build
|
||||
./gori-agent.sh setup --config ./config.json
|
||||
```
|
||||
|
||||
建议向导选择:
|
||||
一台机器上的实例统一位于:
|
||||
|
||||
```text
|
||||
Assistant workspace: /home/ubuntu/gori-space/gori-agent
|
||||
Include ops role with the existing gori-update skill: yes
|
||||
Write config: yes
|
||||
${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances/<bot-id>/
|
||||
├── config.json
|
||||
├── logs/gori-agent.log
|
||||
└── state/
|
||||
├── acp-sessions.json
|
||||
└── gori-agent.pid
|
||||
```
|
||||
|
||||
迁移会把旧的 `kimi -p` 改为 `kimi acp`,并完整保留已有 QQ appId、secret 和连接模式。
|
||||
实例目录就是该进程的 `GORI_AGENT_HOME`。实例目录、`state/`、`logs/` 使用 `0700`,配置与状态文件使用 `0600`。
|
||||
|
||||
### 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[]` 中:
|
||||
|
||||
```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]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
初次测试可让 `allowedUsers` 和 `allowedChats` 为空;正式运维 bot 应限制到指定用户或群。群聊 chat ID 的格式为 `group:<group_openid>`。
|
||||
|
||||
---
|
||||
|
||||
## Detailed reference
|
||||
|
||||
Multi-IM gateway that drives coding agents through the official Agent Client Protocol (ACP):
|
||||
|
||||
```text
|
||||
IM Adapter -> Gateway -> Role/Session Manager -> ACP Client -> kimi acp
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Requires Node.js 20+ and an authenticated Kimi Code installation.
|
||||
## 构建与安装
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
./install.sh
|
||||
gori-agent setup
|
||||
gori-agent doctor --config ./config.json
|
||||
gori-agent start --config ./config.json
|
||||
```
|
||||
|
||||
`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.
|
||||
安装器只安装一个 launcher;不会为每个 Bot 复制源码或 `dist/`。默认 launcher 位于 `$HOME/.gori-agent/bin/gori-agent`。
|
||||
|
||||
Convenience wrapper commands:
|
||||
确认 ACP agent 可用:
|
||||
|
||||
```bash
|
||||
./gori-agent.sh start
|
||||
./gori-agent.sh start-daemon
|
||||
./gori-agent.sh status
|
||||
./gori-agent.sh logs
|
||||
./gori-agent.sh stop
|
||||
kimi --version
|
||||
kimi doctor
|
||||
kimi acp --help
|
||||
gori-agent discover-backends
|
||||
```
|
||||
|
||||
`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.
|
||||
模型和 provider 由 ACP agent 自身配置,例如 Kimi Code 使用 `~/.kimi-code/config.toml`。gori-agent 不复制模型凭据。
|
||||
|
||||
## 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]
|
||||
gori-agent instance init <bot-id>
|
||||
gori-agent instance doctor <bot-id>
|
||||
gori-agent instance start <bot-id>
|
||||
gori-agent instance status <bot-id>
|
||||
gori-agent instance logs <bot-id>
|
||||
gori-agent instance restart <bot-id>
|
||||
gori-agent instance stop <bot-id>
|
||||
gori-agent instance list
|
||||
```
|
||||
|
||||
`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.
|
||||
- `init` 交互生成 Config v3,本身不启动 Bot;它会询问并校验 `gateway.server.host/port`,扫描统一实例目录中其他 Config v3 的声明端口,并在默认端口冲突时建议下一个未占用端口。已有配置通过 `setup` 重跑时可保留或修改 host/port,不会静默改端口。
|
||||
- 平台 secret/token 使用不回显输入,空 generic webhook/weixin secret 会随机生成。
|
||||
- `start` 会检查配置存在、目录名匹配 `bot.id`、目录/配置权限、模板占位符、workspace、agent executable、PID identity 和端口,并等待 `/health` 返回匹配的 Bot/platform identity;任一不满足即 fail closed。
|
||||
- `status` 核对 PID 对应的完整 `start --config` 身份以及 `/health` 返回的 Bot/platform identity。
|
||||
- 每个实例必须使用不同的 `gateway.server.port` 和平台凭据;setup 的声明冲突提示不替代 `start` 的实际端口检查。
|
||||
- `stop` 发送 `SIGTERM` 并等待最多 10 秒,不会自动 `SIGKILL`。
|
||||
|
||||
## Roles, sessions, and commands
|
||||
可用 `GORI_AGENT_ROOT` 改变统一根目录:
|
||||
|
||||
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.
|
||||
```bash
|
||||
GORI_AGENT_ROOT=/srv/gori-agent gori-agent instance list
|
||||
```
|
||||
|
||||
- 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.
|
||||
## `config.example.json` 的定位
|
||||
|
||||
Chat commands:
|
||||
`config.example.json` 是仓库内唯一可提交、无密钥的配置说明,不是运行配置。它可被 Config v3 schema 解析,但保留 `BOT_ID`、`QQ_APP_ID` 等明显占位符。
|
||||
|
||||
- `/help`
|
||||
- `/roles`
|
||||
- `/role <id>`
|
||||
- `/status`
|
||||
- `/cancel`
|
||||
- `/new`
|
||||
- `/agents` and `/agent <id>` (deprecated aliases)
|
||||
`start`、`status`、`doctor`、`print` 找不到本地配置时会失败,绝不会回退到 example。只有 `setup` 和 `instance init` 会读取 example 作为初始化种子。
|
||||
|
||||
Messages in one chat are serialized; different chats run concurrently.
|
||||
底层调试入口仍可使用:
|
||||
|
||||
## Configuration v2
|
||||
```bash
|
||||
gori-agent setup --config /absolute/path/config.json
|
||||
gori-agent start --config /absolute/path/config.json
|
||||
gori-agent status --config /absolute/path/config.json
|
||||
gori-agent doctor --config /absolute/path/config.json
|
||||
gori-agent print feishu --config /absolute/path/config.json
|
||||
```
|
||||
|
||||
See `config.example.json`. Important sections:
|
||||
`writeConfigFile()` 使用同目录临时文件、`fsync` 和原子 rename,并强制最终文件为 `0600`。
|
||||
|
||||
- `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.
|
||||
## Config v3
|
||||
|
||||
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.
|
||||
完整说明见 `config.example.json`。顶层只有:
|
||||
|
||||
## Permission policy
|
||||
```text
|
||||
configVersion 固定为 3
|
||||
bot Bot、workspace、persona、agent、skills、permissions
|
||||
gateway HTTP server、入站 policy、唯一 platform
|
||||
runtime.acp ACP state、timeout 和 worker pool
|
||||
```
|
||||
|
||||
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:
|
||||
### Bot 与 ACP agent
|
||||
|
||||
```json
|
||||
{
|
||||
"chat_id": "demo-chat",
|
||||
"user_id": "demo-user",
|
||||
"text": "/status",
|
||||
"message_id": "optional",
|
||||
"is_group": false,
|
||||
"mentions_bot": true
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `platforms.webhook.secret` is set, provide `X-Gori-Signature: sha256=<HMAC-SHA256 hex>` over the exact JSON body.
|
||||
- `bot.id` 只允许小写字母、数字和连字符,最长 63 字符。
|
||||
- `workspace` 与每个 skill `file` 必须是绝对路径。
|
||||
- `bot.skills[]` 直接声明 `{ id, file, maxBytes }`;ID 必须唯一。
|
||||
- agent 使用 `shell: false` 在 `bot.workspace` 启动。
|
||||
- agent env 可能包含 secret,不能进入可提交模板、日志或 diff。
|
||||
|
||||
## Lifecycle and diagnostics
|
||||
### Permission policy
|
||||
|
||||
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.
|
||||
ACP permission request 由 `bot.permissions` 决策,persona 不是安全边界:
|
||||
|
||||
`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.
|
||||
- `deny`:拒绝所有 permission request,默认值。
|
||||
- `allowlist`:`toolCall.name` 必须与 `allowedTools` 精确匹配;bash/terminal 还必须由至少一个 `allowedCommandPatterns` 正则完整覆盖整条 raw command。名称或 command 缺失时拒绝。
|
||||
- `auto`:自动允许,风险等价于 yolo;`doctor` 会告警。
|
||||
|
||||
## Development
|
||||
`allowedTools` 只控制 ACP 审批,不会注册或创造工具。skills、agent 与 permission policy 是三个独立概念。
|
||||
|
||||
### 单平台配置
|
||||
|
||||
`gateway.platform` 是按 `type` 区分的 union,只能选择一个:
|
||||
|
||||
- `qq`:WebSocket 或 webhook。
|
||||
- `feishu`:`/webhook/feishu`。
|
||||
- `wecom`:`/webhook/wecom`;入站仍是 501 scaffold。
|
||||
- `webhook`:`/webhook/generic`,同步 JSON + HMAC-SHA256。
|
||||
- `weixin`:`/webhook/weixin`,外部 bridge scaffold。
|
||||
|
||||
对象存在即启用,不再使用 `enabled`。Server 只构造所选 adapter,也只挂载需要的 webhook route。QQ WebSocket 仅在 `type=qq` 且 `connectionMode=websocket` 时启动,此模式不挂载 `/webhook/qq`。
|
||||
|
||||
QQ websocket 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "qq",
|
||||
"connectionMode": "websocket",
|
||||
"appId": "QQ_APP_ID",
|
||||
"clientSecret": "QQ_CLIENT_SECRET",
|
||||
"botSecret": "",
|
||||
"verifySignature": true,
|
||||
"botNames": ["QQ_BOT_NAME"],
|
||||
"intents": 33554432,
|
||||
"shard": [0, 1]
|
||||
}
|
||||
```
|
||||
|
||||
正式 Bot 应通过 `gateway.policy.allowedUsers` / `allowedChats` 限制入口。QQ 群 chat ID 为 `group:<group_openid>`。
|
||||
|
||||
### ACP 生命周期
|
||||
|
||||
默认 `runtime.acp.promptTimeoutMs` 是 `7200000`(2 小时),适合长构建/部署任务。其他默认值:
|
||||
|
||||
- initialize:10 秒
|
||||
- cancel grace:5 秒
|
||||
- idle worker:30 分钟
|
||||
- sweep:60 秒
|
||||
- max processes:8
|
||||
|
||||
`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 与安全纪律
|
||||
|
||||
`instance doctor` / `doctor --config` 检查:
|
||||
|
||||
- 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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+27
-132
@@ -2,31 +2,31 @@
|
||||
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
|
||||
Instance commands:
|
||||
instance init <bot-id>
|
||||
instance start <bot-id>
|
||||
instance stop <bot-id>
|
||||
instance restart <bot-id>
|
||||
instance status <bot-id>
|
||||
instance logs <bot-id>
|
||||
instance doctor <bot-id>
|
||||
instance list
|
||||
|
||||
Environment:
|
||||
GORI_GATEWAY_CONFIG Config path, default: $CONFIG_FILE
|
||||
Debug commands:
|
||||
setup [--config path]
|
||||
discover-backends [--json]
|
||||
start --config path
|
||||
status --config path
|
||||
doctor --config path
|
||||
print feishu --config path
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -37,122 +37,17 @@ ensure_build() {
|
||||
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"
|
||||
;;
|
||||
case "$1" in
|
||||
--help|-h|help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo "Unknown command: $COMMAND" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
ensure_build
|
||||
exec node "$ROOT_DIR/dist/cli.js" "$@"
|
||||
|
||||
+11
-8
@@ -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 instance list"
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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]; }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
+49
-12
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
import process from "node:process";
|
||||
import { discoverBackends } from "./acp/discovery.js";
|
||||
import { loadConfigFile } from "./cli/config-file.js";
|
||||
import { assertOperationalConfig, loadConfigFile } from "./cli/config-file.js";
|
||||
import { runDoctor } from "./cli/doctor.js";
|
||||
import { runInstanceCommand } from "./cli/instance.js";
|
||||
import { localBaseUrl } from "./cli/net.js";
|
||||
import { printFeishu } from "./cli/print.js";
|
||||
import { runSetup } from "./cli/setup.js";
|
||||
@@ -14,6 +15,8 @@ interface ParsedArgs { command?: string; rest: string[]; configPath?: string; js
|
||||
async function main(argv: string[]): Promise<number> {
|
||||
const parsed = parseArgs(argv);
|
||||
if (parsed.help || !parsed.command) { printHelp(); return 0; }
|
||||
validateCommandArgs(parsed);
|
||||
if (parsed.command === "instance") return runInstanceCommand(parsed.rest[0], parsed.rest.slice(1));
|
||||
if (parsed.command === "setup") { await runSetup(parsed.configPath); return 0; }
|
||||
if (parsed.command === "discover-backends" || parsed.command === "discover-agents") {
|
||||
const backends = await discoverBackends();
|
||||
@@ -27,6 +30,7 @@ async function main(argv: string[]): Promise<number> {
|
||||
}
|
||||
if (parsed.command === "start") {
|
||||
const loaded = loadConfigFile(parsed.configPath);
|
||||
assertOperationalConfig(loaded.config, loaded.path);
|
||||
const running = await startServer(loaded.config);
|
||||
return await new Promise<number>((resolve) => {
|
||||
let stopping = false;
|
||||
@@ -44,10 +48,10 @@ async function main(argv: string[]): Promise<number> {
|
||||
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;
|
||||
await printFeishu(loaded.config);
|
||||
return 0;
|
||||
}
|
||||
console.error(`Unknown command: ${parsed.command}`); printHelp(); return 1;
|
||||
throw new Error(`Unknown command: ${parsed.command}`);
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): ParsedArgs {
|
||||
@@ -56,19 +60,52 @@ function parseArgs(argv: string[]): ParsedArgs {
|
||||
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);
|
||||
else if (arg === "--config") {
|
||||
if (configPath !== undefined) throw new Error("--config may only be specified once");
|
||||
const value = argv[++index];
|
||||
if (!value || value.startsWith("-")) throw new Error("--config requires a path");
|
||||
configPath = value;
|
||||
} else if (arg.startsWith("-")) throw new Error(`Unknown option: ${arg}`);
|
||||
else if (!command) command = arg;
|
||||
else rest.push(arg);
|
||||
}
|
||||
return { command, rest, configPath, json, help };
|
||||
}
|
||||
|
||||
function validateCommandArgs(parsed: ParsedArgs): void {
|
||||
const { command, rest, configPath, json } = parsed;
|
||||
if (command === "instance") {
|
||||
if (configPath || json) throw new Error("instance commands do not accept --config or --json");
|
||||
return;
|
||||
}
|
||||
if (command === "setup") {
|
||||
if (json || rest.length > 0) throw new Error("setup accepts only --config <path>");
|
||||
return;
|
||||
}
|
||||
if (command === "discover-backends" || command === "discover-agents") {
|
||||
if (configPath || rest.length > 0) throw new Error(`${command} accepts only --json`);
|
||||
return;
|
||||
}
|
||||
if (["start", "status", "doctor"].includes(command || "")) {
|
||||
if (json || rest.length > 0) throw new Error(`${command} accepts only --config <path>`);
|
||||
return;
|
||||
}
|
||||
if (command === "print") {
|
||||
if (json || rest.length !== 1 || rest[0] !== "feishu") throw new Error("print requires exactly the topic 'feishu'");
|
||||
return;
|
||||
}
|
||||
throw new Error(`Unknown command: ${command}`);
|
||||
}
|
||||
|
||||
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(`Config: ${loaded.path}`);
|
||||
console.log(`Config version: ${loaded.config.configVersion}`);
|
||||
console.log(`Bot: ${loaded.config.bot.id}`);
|
||||
console.log(`Agent: ${loaded.config.bot.agent.id}`);
|
||||
console.log(`Platform: ${loaded.config.gateway.platform.type}`);
|
||||
console.log(`Server: ${loaded.config.gateway.server.host}:${loaded.config.gateway.server.port}`);
|
||||
console.log(`Workspace: ${loaded.config.bot.workspace}`);
|
||||
console.log(`State file: ${defaultStateFile(loaded.config)}`);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(1_000) });
|
||||
@@ -77,7 +114,7 @@ async function printStatus(configPath?: string): Promise<void> {
|
||||
}
|
||||
|
||||
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\n\nUsage:\n gori-agent instance init <bot-id>\n gori-agent instance start <bot-id>\n gori-agent instance stop <bot-id>\n gori-agent instance restart <bot-id>\n gori-agent instance status <bot-id>\n gori-agent instance logs <bot-id>\n gori-agent instance doctor <bot-id>\n gori-agent instance list\n\nDebug commands:\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\nInstances root: \${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances`);
|
||||
}
|
||||
|
||||
main(process.argv.slice(2)).then((code) => { if (Number.isInteger(code)) process.exitCode = code; }).catch((error) => {
|
||||
|
||||
+98
-36
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
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 { 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("instance list does not accept <bot-id>");
|
||||
return listInstances();
|
||||
}
|
||||
if (!action || !["init", "start", "stop", "restart", "status", "logs", "doctor"].includes(action)) {
|
||||
throw new Error(`Unknown instance command: ${action || "(missing)"}`);
|
||||
}
|
||||
if (args.length !== 1) throw new Error(`instance ${action} requires exactly one <bot-id>`);
|
||||
const botId = args[0];
|
||||
validateBotId(botId);
|
||||
switch (action) {
|
||||
case "init": return initInstance(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 instance 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 startInstance(botId: string): Promise<number> {
|
||||
const loaded = loadInstance(botId);
|
||||
assertOperationalConfig(loaded.config);
|
||||
assertConfigMode(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 cliFile = path.join(path.resolve(new URL("../..", import.meta.url).pathname), "dist", "cli.js");
|
||||
let child: ReturnType<typeof spawn> | undefined;
|
||||
try {
|
||||
child = spawn(process.execPath, [cliFile, "start", "--config", loaded.path], {
|
||||
cwd: paths.home,
|
||||
env: { ...process.env, GORI_AGENT_HOME: paths.home, GORI_GATEWAY_CONFIG: loaded.path },
|
||||
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);
|
||||
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("Refusing to start: config must be a regular file");
|
||||
const mode = stat.mode & 0o777;
|
||||
if (mode !== 0o600) throw new Error(`Refusing to start: config file mode must be 0600, got 0${mode.toString(8)}`);
|
||||
}
|
||||
|
||||
type PidInspection = { kind: "absent" } | { kind: "stale"; dev?: number; ino?: number } | { kind: "running" | "foreign"; pid: number };
|
||||
|
||||
function inspectPid(pidFile: string, configFile: string): PidInspection {
|
||||
let text: string;
|
||||
let stat: fs.Stats;
|
||||
try { stat = fs.lstatSync(pidFile); text = fs.readFileSync(pidFile, "utf8").trim(); }
|
||||
catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? { kind: "absent" } : { kind: "stale" }; }
|
||||
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 configIndex = commandLine.indexOf("--config");
|
||||
const matches = commandLine[2] === "start"
|
||||
&& commandLine[1]?.endsWith("/dist/cli.js")
|
||||
&& configIndex >= 0
|
||||
&& path.resolve(commandLine[configIndex + 1] || "") === path.resolve(configFile);
|
||||
return { kind: matches ? "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
@@ -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);
|
||||
}
|
||||
|
||||
+3
-7
@@ -2,17 +2,13 @@ import type { AppConfig } from "../config.js";
|
||||
import { webhookBaseUrl } from "./net.js";
|
||||
|
||||
export async function printFeishu(config: AppConfig): Promise<void> {
|
||||
if (config.gateway.platform.type !== "feishu") throw new Error("Current instance platform is not Feishu");
|
||||
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("Required config fields: gateway.platform.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.");
|
||||
}
|
||||
if (!config.gateway.server.publicBaseUrl) console.log("Warning: gateway.server.publicBaseUrl is empty; configure your public HTTPS URL before production use.");
|
||||
}
|
||||
|
||||
+20
-1
@@ -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
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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") };
|
||||
}
|
||||
|
||||
+161
-53
@@ -1,66 +1,174 @@
|
||||
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<void> {
|
||||
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();
|
||||
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 (ready.length === 0) throw new Error("No ACP agent is available");
|
||||
const selected = 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 = options.botId || await prompt.ask("Bot ID", current.bot.id === "BOT_ID" ? undefined : current.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: {
|
||||
id: selected.id,
|
||||
command: selected.command,
|
||||
args: selected.args,
|
||||
env: !isTemplate && selected.id === current.bot.agent.id ? current.bot.agent.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; }
|
||||
writeConfigFile(target, next);
|
||||
log(`Wrote ${target} with mode 0600`);
|
||||
} 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
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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" });
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
function runCli(args: string[]) {
|
||||
return spawnSync(process.execPath, ["--import", "tsx", path.resolve("src/cli.ts"), ...args], {
|
||||
cwd: path.resolve("."),
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, GORI_GATEWAY_CONFIG: "" }
|
||||
});
|
||||
}
|
||||
|
||||
test("CLI rejects unknown flags and command-specific extra arguments", () => {
|
||||
const unknown = runCli(["status", "--bogus"]);
|
||||
assert.equal(unknown.status, 1);
|
||||
assert.match(unknown.stderr, /Unknown option: --bogus/);
|
||||
|
||||
const extra = runCli(["doctor", "extra"]);
|
||||
assert.equal(extra.status, 1);
|
||||
assert.match(extra.stderr, /doctor accepts only --config/);
|
||||
|
||||
const instanceFlag = runCli(["instance", "list", "--config", "config.json"]);
|
||||
assert.equal(instanceFlag.status, 1);
|
||||
assert.match(instanceFlag.stderr, /instance commands do not accept/);
|
||||
});
|
||||
+57
-21
@@ -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]/);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Vendored
+10
-2
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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 { writeConfigFile } from "../src/cli/config-file.js";
|
||||
import { instanceDirectory, runInstanceCommand } from "../src/cli/instance.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 instance 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;
|
||||
}
|
||||
});
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
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: readyAgent.id, command: readyAgent.command, args: readyAgent.args, env: {} },
|
||||
skills: [],
|
||||
permissions: { mode: "deny", allowedTools: [], allowedCommandPatterns: [] }
|
||||
},
|
||||
gateway: {
|
||||
server: { host, port, publicBaseUrl: "https://public.example.test" },
|
||||
policy: { allowedUsers: [], allowedChats: [], requireMentionInGroup: true },
|
||||
platform: { type: "webhook", secret: testSecret }
|
||||
},
|
||||
runtime: {}
|
||||
});
|
||||
}
|
||||
|
||||
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.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);
|
||||
});
|
||||
Reference in New Issue
Block a user