diff --git a/AGENTS.md b/AGENTS.md index dc054c7..db0914d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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// +├── 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 +gori-agent instance start +gori-agent instance stop +gori-agent instance restart +gori-agent instance status +gori-agent instance logs +gori-agent instance list +gori-agent instance doctor ``` -`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:`。 -- 正式运维 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:`。正式运维 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。实例默认在仓库外。安全性按内容判断,不能只依赖文件名。 diff --git a/README.md b/README.md index 9855239..33f1c67 100644 --- a/README.md +++ b/README.md @@ -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// +├── 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 切换角色 -/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:`。 - ---- - -## 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 +gori-agent instance doctor +gori-agent instance start +gori-agent instance status +gori-agent instance logs +gori-agent instance restart +gori-agent instance stop +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 ` -- `/status` -- `/cancel` -- `/new` -- `/agents` and `/agent ` (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=` 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:`。 + +### 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/`:只存在所选平台路由;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。 diff --git a/config.example.json b/config.example.json index f2e70f8..0bdbe81 100644 --- a/config.example.json +++ b/config.example.json @@ -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 } } } diff --git a/gori-agent.sh b/gori-agent.sh index 3773f36..c0d6f83 100755 --- a/gori-agent.sh +++ b/gori-agent.sh @@ -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 < [--config path] + cat <<'EOF' +Usage: gori-agent [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 + instance start + instance stop + instance restart + instance status + instance logs + instance doctor + 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/. +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" "$@" diff --git a/install.sh b/install.sh index da7a6ed..0a1b6d1 100755 --- a/install.sh +++ b/install.sh @@ -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" <>"$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" diff --git a/package.json b/package.json index 856b237..142702d 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/acp/backend-registry.ts b/src/acp/backend-registry.ts index cd019e4..ec5e977 100644 --- a/src/acp/backend-registry.ts +++ b/src/acp/backend-registry.ts @@ -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(); - - 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]; } } diff --git a/src/acp/backends/kimi.ts b/src/acp/backends/kimi.ts index d7f4281..5f843a8 100644 --- a/src/acp/backends/kimi.ts +++ b/src/acp/backends/kimi.ts @@ -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 }; } diff --git a/src/acp/client.ts b/src/acp/client.ts index 3c3ac3b..ad42bd2 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -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; + 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" } }; diff --git a/src/acp/probe.ts b/src/acp/probe.ts index c7a5aac..dcea586 100644 --- a/src/acp/probe.ts +++ b/src/acp/probe.ts @@ -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 { - 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 { + 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"); } } diff --git a/src/acp/session-manager.ts b/src/acp/session-manager.ts index 36d1436..16ac1d1 100644 --- a/src/acp/session-manager.ts +++ b/src/acp/session-manager.ts @@ -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(); @@ -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 { 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 { 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 { - 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 { 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 { - const role = this.roles.get(roleId); + private async acquireWorker(binding?: SessionBinding): Promise { 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 { - 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 { - const key = workerKey(backendId, nativeSessionId); + private async stopWorker(agentId: string, nativeSessionId: string): Promise { + 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}`; } diff --git a/src/acp/types.ts b/src/acp/types.ts index 41f0359..d5c096f 100644 --- a/src/acp/types.ts +++ b/src/acp/types.ts @@ -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; cancel(platform: string, chatId: string): Promise; reset(platform: string, chatId: string): Promise; - selectRole(platform: string, chatId: string, roleId: string): Promise; - selectedRole(platform: string, chatId: string): string; status(platform: string, chatId: string): Record; stats(): RuntimeStats; shutdown(): Promise; } export interface PermissionContext { - policy: RolePolicy; + policy: PermissionPolicy; } diff --git a/src/acp/worker.ts b/src/acp/worker.ts index 335fbc4..ca4e003 100644 --- a/src/acp/worker.ts +++ b/src/acp/worker.ts @@ -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 { - 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 { diff --git a/src/cli.ts b/src/cli.ts index bf5b1e9..7c4b865 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 { 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 { } if (parsed.command === "start") { const loaded = loadConfigFile(parsed.configPath); + assertOperationalConfig(loaded.config, loaded.path); const running = await startServer(loaded.config); return await new Promise((resolve) => { let stopping = false; @@ -44,10 +48,10 @@ async function main(argv: string[]): Promise { 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 "); + 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 `); + 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 { 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 { } 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 \n gori-agent instance start \n gori-agent instance stop \n gori-agent instance restart \n gori-agent instance status \n gori-agent instance logs \n gori-agent instance doctor \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) => { diff --git a/src/cli/config-file.ts b/src/cli/config-file.ts index eaaf058..4eb1cf3 100644 --- a/src/cli/config-file.ts +++ b/src/cli/config-file.ts @@ -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; } diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 24ac68b..e78061c 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -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 { 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; } diff --git a/src/cli/instance.ts b/src/cli/instance.ts new file mode 100644 index 0000000..68b8a33 --- /dev/null +++ b/src/cli/instance.ts @@ -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 { + if (action === "list") { + if (args.length !== 0) throw new Error("instance list does not accept "); + 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 `); + 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 { + 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 { + 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 | 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((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 { + 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 { + 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 { + 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((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 { + 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 { + 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): 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 { + 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[0]): Promise { + 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 { return new Promise((resolve) => setTimeout(resolve, ms)); } + +async function terminateStartedChild(pid: number): Promise { + 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(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(home: string, fn: () => Promise): Promise { + 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; +} diff --git a/src/cli/net.ts b/src/cli/net.ts index b65a531..6c56ed6 100644 --- a/src/cli/net.ts +++ b/src/cli/net.ts @@ -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 { - 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 { 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 { - return config.server.publicBaseUrl?.replace(/\/$/, "") || await publicBaseUrlHint(config) || localBaseUrl(config); + return config.gateway.server.publicBaseUrl?.replace(/\/$/, "") || await publicBaseUrlHint(config) || localBaseUrl(config); } diff --git a/src/cli/print.ts b/src/cli/print.ts index 57d0521..9316029 100644 --- a/src/cli/print.ts +++ b/src/cli/print.ts @@ -2,17 +2,13 @@ import type { AppConfig } from "../config.js"; import { webhookBaseUrl } from "./net.js"; export async function printFeishu(config: AppConfig): Promise { + 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."); } diff --git a/src/cli/prompt.ts b/src/cli/prompt.ts index 44265c2..229c7fe 100644 --- a/src/cli/prompt.ts +++ b/src/cli/prompt.ts @@ -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; + askSecret(question: string, hasExisting?: boolean): Promise; askBoolean(question: string, defaultValue?: boolean): Promise; askList(question: string, defaultValues?: string[]): Promise; choose(question: string, choices: Choice[], defaultIndex?: number): Promise; @@ -16,9 +18,14 @@ export interface Choice { } 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 { + 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 { const suffix = defaultValue !== undefined && defaultValue !== "" ? ` [${defaultValue}]` : ""; const answer = (await rl.question(`${question}${suffix}: `)).trim(); diff --git a/src/cli/setup-feishu.ts b/src/cli/setup-feishu.ts index 09d22b5..6df7f28 100644 --- a/src/cli/setup-feishu.ts +++ b/src/cli/setup-feishu.ts @@ -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 { - 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 { 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) }; } diff --git a/src/cli/setup-server.ts b/src/cli/setup-server.ts new file mode 100644 index 0000000..b63129c --- /dev/null +++ b/src/cli/setup-server.ts @@ -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 { + 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 { + const used = new Set(); + 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; +} diff --git a/src/cli/setup-webhook.ts b/src/cli/setup-webhook.ts index e24e672..f1ac977 100644 --- a/src/cli/setup-webhook.ts +++ b/src/cli/setup-webhook.ts @@ -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 { - 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 { + 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") }; } diff --git a/src/cli/setup-weixin.ts b/src/cli/setup-weixin.ts index 374a332..1dc8c50 100644 --- a/src/cli/setup-weixin.ts +++ b/src/cli/setup-weixin.ts @@ -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 { - 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 { + 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") }; } diff --git a/src/cli/setup.ts b/src/cli/setup.ts index c2bd8f2..2642baf 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -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 { - const loaded = loadConfigFile(configPath); - const prompt = createPromptSession(); +export async function runSetup(configPath?: string, options: SetupOptions = {}): Promise { + 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, + isNew: boolean, + log: (message: string) => void +): Promise { + 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 { + 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") }; + } + } } diff --git a/src/config.ts b/src/config.ts index f6f3023..5a48714 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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; -export type GatewayPolicy = z.infer; -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; +export type FeishuConfig = Extract; +export type WeComConfig = Extract; +export type WebhookConfig = Extract; +export type WeixinConfig = Extract; // 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`); } diff --git a/src/core/command-router.ts b/src/core/command-router.ts index afe344f..2882f76 100644 --- a/src/core/command-router.ts +++ b/src/core/command-router.ts @@ -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; } } diff --git a/src/core/durable-session-store.ts b/src/core/durable-session-store.ts index c29c5de..3bdb240 100644 --- a/src/core/durable-session-store.ts +++ b/src/core/durable-session-store.ts @@ -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; + version: 2; + botId: string; + platform: string; bindings: Record; } -const EMPTY: StoreData = { version: 1, chats: {}, bindings: {} }; - export class DurableSessionStore { - private data: StoreData = structuredClone(EMPTY); + private data: StoreData; private queue: Promise = 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 { - 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 { - 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 { - 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 { - const binding = this.data.bindings[bindingKey(chatKey, roleId)]; + async touchBinding(chatKey: string): Promise { + const binding = this.data.bindings[chatKey]; if (!binding) return; binding.updatedAt = Date.now(); await this.persist(); } - async deleteBinding(chatKey: string, roleId: string): Promise { - const key = bindingKey(chatKey, roleId); - const existing = this.data.bindings[key]; - delete this.data.bindings[key]; + async deleteBinding(chatKey: string): Promise { + 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 { await this.queue; } @@ -102,15 +109,15 @@ export class DurableSessionStore { async close(): Promise { if (this.closed) return; this.closed = true; - await this.flush(); - await this.releaseLock(); + try { await this.flush(); } finally { await this.releaseLock(); } } private persist(): Promise { 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 { @@ -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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} async function acquireLock(lockFile: string): Promise { return fs.promises.open(lockFile, "wx", 0o600); } +async function removeStaleLock(lockFile: string): Promise { + 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 { 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; } diff --git a/src/core/gateway.ts b/src/core/gateway.ts index f6a7c5e..59be792 100644 --- a/src/core/gateway.ts +++ b/src/core/gateway.ts @@ -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>(); 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 { 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 & { lockedChats: number } { return { ...this.runtime.stats(), lockedChats: this.locks.size }; } private async executeCommand(command: ParsedCommand, message: IncomingMessage): Promise { - const prefix = command.deprecatedAlias ? "Deprecated alias; use /role or /roles.\n" : ""; switch (command.kind) { - case "help": return ["Commands:", "/roles", "/role ", "/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 `; - 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; } diff --git a/src/platforms/feishu/adapter.ts b/src/platforms/feishu/adapter.ts index bf3d1a5..e7f7d52 100644 --- a/src/platforms/feishu/adapter.ts +++ b/src/platforms/feishu/adapter.ts @@ -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()); diff --git a/src/platforms/qq/adapter.ts b/src/platforms/qq/adapter.ts index a804b2a..801e28b 100644 --- a/src/platforms/qq/adapter.ts +++ b/src/platforms/qq/adapter.ts @@ -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()); diff --git a/src/platforms/qq/gateway-client.ts b/src/platforms/qq/gateway-client.ts index 921ceef..d3931a7 100644 --- a/src/platforms/qq/gateway-client.ts +++ b/src/platforms/qq/gateway-client.ts @@ -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; } diff --git a/src/platforms/webhook/adapter.ts b/src/platforms/webhook/adapter.ts index 29f2caa..4bae746 100644 --- a/src/platforms/webhook/adapter.ts +++ b/src/platforms/webhook/adapter.ts @@ -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 ) {} diff --git a/src/platforms/wecom/adapter.ts b/src/platforms/wecom/adapter.ts index f566ce3..534a145 100644 --- a/src/platforms/wecom/adapter.ts +++ b/src/platforms/wecom/adapter.ts @@ -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 { 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()); diff --git a/src/platforms/weixin/adapter.ts b/src/platforms/weixin/adapter.ts index cfe1605..5b2f153 100644 --- a/src/platforms/weixin/adapter.ts +++ b/src/platforms/weixin/adapter.ts @@ -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 ) {} diff --git a/src/roles/role-registry.ts b/src/roles/role-registry.ts index 3e5f957..9b3fa4c 100644 --- a/src/roles/role-registry.ts +++ b/src/roles/role-registry.ts @@ -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(); +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"); } diff --git a/src/server.ts b/src/server.ts index da44dcd..66792cb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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; } export async function createGatewayRuntime(config: AppConfig): Promise { - 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 | 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 | 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 { 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 } export async function startServer(config: AppConfig): Promise { 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((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 | undefined; return { server, runtime, shutdown: () => closing ||= (async () => { runtime.qqGatewayClient?.stop(); - const runtimeShutdown = runtime.shutdown(); - await new Promise((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 { + if (!server.listening) return Promise.resolve(); + return new Promise((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; }); } diff --git a/test/acp-client.test.ts b/test/acp-client.test.ts index 442bf04..df75788 100644 --- a/test/acp-client.test.ts +++ b/test/acp-client.test.ts @@ -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 => ({ 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" }); }); diff --git a/test/acp-session-manager.test.ts b/test/acp-session-manager.test.ts index 1d226bb..4db54f9 100644 --- a/test/acp-session-manager.test.ts +++ b/test/acp-session-manager.test.ts @@ -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 = {}) { 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 = {}) { + 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(); }); diff --git a/test/acp-worker.test.ts b/test/acp-worker.test.ts index dd8a62f..84345cb 100644 --- a/test/acp-worker.test.ts +++ b/test/acp-worker.test.ts @@ -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 }; } diff --git a/test/cli-config-file.test.ts b/test/cli-config-file.test.ts new file mode 100644 index 0000000..079faa9 --- /dev/null +++ b/test/cli-config-file.test.ts @@ -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/); + } +}); diff --git a/test/cli.test.ts b/test/cli.test.ts new file mode 100644 index 0000000..7ff9cb0 --- /dev/null +++ b/test/cli.test.ts @@ -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/); +}); diff --git a/test/config.test.ts b/test/config.test.ts index d4ffd43..1c6ffa5 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -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 = {}) { + 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]/); }); diff --git a/test/durable-session-store.test.ts b/test/durable-session-store.test.ts index 31eaeae..c7094cb 100644 --- a/test/durable-session-store.test.ts +++ b/test/durable-session-store.test.ts @@ -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); }); diff --git a/test/fixtures/fake-acp-agent.mjs b/test/fixtures/fake-acp-agent.mjs index 8ead3fc..2783afe 100644 --- a/test/fixtures/fake-acp-agent.mjs +++ b/test/fixtures/fake-acp-agent.mjs @@ -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 }) => { diff --git a/test/gateway.test.ts b/test/gateway.test.ts index bb6fcb8..2b27210 100644 --- a/test/gateway.test.ts +++ b/test/gateway.test.ts @@ -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((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((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); }); diff --git a/test/instance.test.ts b/test/instance.test.ts new file mode 100644 index 0000000..7d3ce30 --- /dev/null +++ b/test/instance.test.ts @@ -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; + } +}); diff --git a/test/qq-adapter.test.ts b/test/qq-adapter.test.ts index 8030388..0980b6b 100644 --- a/test/qq-adapter.test.ts +++ b/test/qq-adapter.test.ts @@ -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; } diff --git a/test/server.test.ts b/test/server.test.ts new file mode 100644 index 0000000..42a2c77 --- /dev/null +++ b/test/server.test.ts @@ -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((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((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((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((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((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((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; + } +}); diff --git a/test/setup-server.test.ts b/test/setup-server.test.ts new file mode 100644 index 0000000..0278a13 --- /dev/null +++ b/test/setup-server.test.ts @@ -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]); +}); diff --git a/test/setup.test.ts b/test/setup.test.ts new file mode 100644 index 0000000..19d86b8 --- /dev/null +++ b/test/setup.test.ts @@ -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(); + closeCount = 0; + + constructor( + private readonly answers: Record = {}, + private readonly secretAnswer = testSecret + ) {} + + async ask(question: string, defaultValue?: string): Promise { + this.defaults.set(question, defaultValue); + const queued = this.answers[question]; + return queued && queued.length > 0 ? queued.shift()! : defaultValue || ""; + } + + async askSecret(): Promise { return this.secretAnswer; } + async askBoolean(_question: string, defaultValue = false): Promise { return defaultValue; } + async askList(_question: string, defaultValues: string[] = []): Promise { return defaultValues; } + + async choose(_question: string, choices: Choice[], defaultIndex = 0): Promise { + 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); +});