Compare commits

...

21 Commits

Author SHA1 Message Date
zenord e9f1d595fa fix: avoid blocking on the task that just started
When an assistant turn emits both confirm and start_next, confirm may
already start the newly queued proposal. The later start_next then sees
the just-started task as an active blocker and appends a false running-task correction. Suppress that redundant start_next correction when confirm in the same turn already started the current user proposal. Also tighten the worker bootstrap wording to run confirmed low-risk work continuously until a real decision point.
2026-08-22 00:15:32 +08:00
zenord 9ae2639660 feat: run confirmed proposals continuously until a real decision point
A proposal still needs one confirmation before it starts, but once it is
working the worker now treats that as a single grant to carry out
routine low-risk execution without step-by-step reconfirmation. It only
returns pending early for high-risk actions, key business decisions,
external blockers, or dirty/unexpected targets. Assistant/worker
bootstrap copy and docs now align with that execution model.
2026-08-22 00:05:40 +08:00
zenord 5229059fe3 feat: share finish stop and cancel across the board
Proposal visibility is already global; this change makes finish, stop,
and cancel shared queue-management actions so any user can unblock the
single worker and shared queue. Confirm, adjust, follow_up, and
start_next remain owner-only. Assistant/bootstrap copy, /help, /list,
and runtime checks now align with that split.
2026-08-22 00:02:26 +08:00
zenord 91327dc632 feat: share the proposal board across chats
The unfinished-proposal board is now visible to every conversation,
with entries marked scope=own/scope=other (chat type only, no IDs), so
anyone can see what the single worker is busy with. All actions remain
owner-only, and pending reminders still target only the owner.
2026-08-19 17:08:48 +08:00
zenord f17fee7383 feat: show live worker status card to all users during debugging
Non-owners now see the same real-time status card as the owner, marked
as another user's proposal, instead of a desensitized busy line. This
is a debugging-phase relaxation; restore owner-only visibility for
production.
2026-08-19 16:43:53 +08:00
zenord 4f7b4a9115 feat: show owners a live worker status snapshot
While a worker turn is running, the assistant prompt for the working
proposal's owner includes a compact status card (elapsed time, last
tool activity category with age, per-category counts) built from the
ACP session/update stream the runtime already receives. Other users
still see only the desensitized busy state, and raw update content
never enters the assistant session.
2026-08-19 16:39:42 +08:00
zenord bd96595ba8 feat: reset idle assistant sessions after one hour
When a conversation's assistant session has been idle longer than
runtime.acp.assistantSessionResetIdleMs (default 1h, 0 disables) and
its owner has no unfinished proposals, the next inbound message starts
a fresh session instead of resuming. Binding lastActiveAt is persisted
per turn so the decision survives restarts.
2026-08-19 16:17:54 +08:00
zenord 9c3139380d fix: salvage truncated worker envelopes and log worker lifecycle
Models occasionally drop the closing tag of the result envelope while
the JSON itself is complete; the strict parser rejected these and one
repair attempt could not always recover, cascading into worker_error
and dropping valid attachments. The parser now falls back to
brace-balanced salvage when the closing tag is missing, and worker
lifecycle events (start, resume, invalid envelope, repair, settle,
worker_error) are logged.
2026-08-19 15:44:53 +08:00
zenord 6b506b8c55 feat: send workspace images to QQ chats
Worker results may report image attachments stored inside the workspace;
the runtime validates them (containment, png/jpg magic, size) and
delivers them with the pending event. Assistants gain a send_image
action so users can ask for an image later. QQ uploads via /files with
base64 file_data and sends msg_type 7 rich media, sharing the same
msg_id/msg_seq counter as text replies; non-image adapters flatten
images to text.
2026-08-19 14:39:18 +08:00
zenord 7aa610294c feat: pending-finish proposals and QQ image input
Proposal semantics: worker results no longer distinguish success/fail;
only an explicit finish settles a pending proposal. Pending owner input
follows up by resuming the original worker session. Dirty pending blocks
start_next globally; clean pending only blocks its owner.

QQ adapter now downloads image attachments and passes them as ACP image
content blocks; video/file attachments degrade to link text.
2026-08-19 13:36:23 +08:00
zenord 33854721bf docs: add gori-agent deployment skill 2026-08-18 22:57:23 +08:00
zenord 2409922d55 feat: split assistant persona from worker persona 2026-08-18 22:49:05 +08:00
zenord ffedf616e3 fix: use passive QQ event replies 2026-08-18 22:20:55 +08:00
zenord 4af5049605 fix: scope proposals to chat users 2026-08-18 20:58:08 +08:00
zenord b4121c9fd6 feat: add assistant proposal worker runtime 2026-08-18 18:48:47 +08:00
zenord 947f19e3cd refine long-running task notices 2026-08-17 16:35:23 +08:00
zenord 680a449cd8 feat: delay long-running task notices 2026-08-17 15:35:18 +08:00
zenord 2795567f8c feat: expose bot task progress 2026-08-17 14:37:51 +08:00
zenord 91bf4e7a0c Upgrade existing launcher profile blocks 2026-08-17 00:58:13 +08:00
zenord fe7bbd0631 Simplify instance lifecycle commands 2026-08-17 00:49:29 +08:00
zenord f9fb4ef776 Refactor runtime around isolated bot instances 2026-08-16 23:45:09 +08:00
67 changed files with 8383 additions and 1845 deletions
@@ -0,0 +1,193 @@
---
name: gori-agent-deploy
description: gori-agent Bot 的代码发布、实例配置、Assistant/Worker 模型与讲话人格配置流程
whenToUse: 当用户要求部署、更新、重启、配置 gori-agent Bot(如 gori-ops、gori-developer),或要求配置 Assistant/Worker 模型、effort、persona/assistantPersona 时使用
---
# gori-agent Bot 部署与配置流程
本 skill 用于把 `gori-agent` 代码发布到运行机,并配置实例的 Assistant/Worker Kimi Code 模型与讲话人格。核心边界:Assistant 只聊天和调度,Worker 只在已确认 Proposal 中执行;不要绕过 Proposal 状态机。
## 固定路径
- 仓库:`/home/ubuntu/gori-space/gori-agent`
- 实例根:`${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances/<bot-id>/`
- 实例配置:`<instance>/config.json`
- 实例 state:`<instance>/state/`
- Assistant Kimi home:`<instance>/state/kimi/assistant/`
- Worker Kimi home:`<instance>/state/kimi/worker/`
- 本机 Kimi 基础配置:`$HOME/.kimi-code/config.toml`
- Kimi OAuth/credentials:`$HOME/.kimi-code/oauth/`、`$HOME/.kimi-code/credentials/`
## 安全纪律
- 不在回复、日志、diff 中输出 platform secret、API key、OAuth 内容、消息正文、完整 chat/user ID。
- `git commit/push/pull --rebase/reset/clean` 等 git mutation 必须得到用户当次明确授权。
- 远程机器写入、部署、重启真实 Bot 必须得到用户当次明确授权。
- 改实例 `config.json` 前必须同目录备份,写入用临时文件 + fsync + atomic rename,最终 `0600`。
- 旧 state 不删除;需要切换版本时改成时间戳 `.bak` 保留。
## 发布流程
1. **确认源仓库状态**
- `git status --short --branch`
- 如果本地有未提交改动且用户要求远端部署,先提示:远端 pull 拿不到未提交代码。
- 只有用户明确授权后才 commit/push。
2. **本地验收**
```bash
npm run typecheck
npm test
npm run build
git diff --check
bash -n install.sh gori-agent.sh bin/gori-agent
```
3. **目标机更新**
```bash
ssh ubuntu@<host> 'cd /home/ubuntu/gori-space/gori-agent && git pull --ff-only origin master && npm run typecheck && npm test && npm run build'
```
- `git pull --ff-only` 失败、工作区脏或分叉时停止并报告,不擅自 reset/rebase。
4. **实例配置**
- 先备份 `<instance>/config.json`。
- Kimi ACP `bot.agent.args` 必须严格为 `["acp"]`。
- Worker 如需独立 Kimi home,设置:
```json
"bot": {
"agent": {
"env": { "KIMI_CODE_HOME": "<instance>/state/kimi/worker" }
}
}
```
- `runtime.acp.promptTimeoutMs` 当前基线为 `14400000`(4 小时)。
- `runtime.acp.maxAssistantSessions` 当前基线为 `4`。
5. **旧 state 处理**
- state v3 会拒绝旧 v1/v2。
- 升级时如需 fresh start,把旧文件移动为:
```text
acp-sessions.json.YYYYMMDD-HHMMSS.bak
```
- 不覆盖、不删除旧 state。
6. **doctor / restart / status**
```bash
bin/gori-agent doctor <bot-id>
bin/gori-agent restart <bot-id>
bin/gori-agent status <bot-id>
```
- Health 必须是 HTTP 200 且 bot/platform identity verified。
- restart 前检查是否有 `working` Proposal;working 时先告知用户,避免把 worker 打成 `worker_lost`(重启后该 Proposal 会变为 `pending` 且 `workspaceDirty: true`,需要用户 finish 或 follow_up,并会全局阻塞 start_next)。
## Assistant / Worker 模型配置
Assistant 和 Worker 可以有独立 `KIMI_CODE_HOME`:
```text
Assistant: <instance>/state/kimi/assistant/config.toml
Worker: <instance>/state/kimi/worker/config.toml
```
流程:
1. 从本机基础配置复制:
```bash
cp "$HOME/.kimi-code/config.toml" "<instance>/state/kimi/assistant/config.toml"
cp "$HOME/.kimi-code/config.toml" "<instance>/state/kimi/worker/config.toml"
```
2. 如果使用 managed Kimi 模型,复制 OAuth/credentials:
```bash
cp -a "$HOME/.kimi-code/oauth/." "<instance>/state/kimi/<role>/oauth/"
cp -a "$HOME/.kimi-code/credentials/." "<instance>/state/kimi/<role>/credentials/"
```
目录 `0700`,文件 `0600`,不回显内容。
3. 设置:
```toml
default_model = "<model-alias>"
[thinking]
enabled = true
effort = "<effort>"
```
4. 校验:
```bash
"$HOME/.kimi-code/bin/kimi" doctor config "<instance>/state/kimi/<role>/config.toml"
```
当前生产基线(2026-08-18):
| Bot | Assistant | Worker |
|---|---|---|
| `gori-ops` | `kimi-code/k3-256k`, `high` | `kimi-code/k3-256k`, `high` |
| `gori-developer` | `kimi-code/k3-256k`, `high` | `kimi-code/k3`, `high` |
注意:`k3-256k` 支持 `low/high/max`,不支持 `medium`;写 `medium` 会按模型默认回落。模型 availability 和 effort 通道异常时,先用只读/小请求验证,再调整。
## Assistant / Worker 人格配置
Config v3 支持:
```json
{
"bot": {
"persona": "Worker 的执行边界和安全规则。",
"assistantPersona": "Assistant 的讲话人格。"
}
}
```
- Worker 永远使用 `bot.persona`。
- Assistant 使用 `bot.assistantPersona`;为空时回退 `bot.persona`。
- `assistantPersona` 参与 Bot fingerprint;修改后旧 Assistant binding 会因 fingerprint 不匹配自动重建。
- Assistant 的人话风格由 bootstrap 统一约束;实例 persona 决定角色气质。
当前实例人格:
### gori-ops
```text
你是 Gori 运维 Assistant。说话像一个靠谱、轻松的运维同事:先给结论,再给原因和下一步;用短句,少术语,不机械复读状态字段。遇到故障先稳住用户,再给可操作选择;不确定就直说。涉及部署、删除、Git mutation、远程写入或共享状态时,用平实的话说明影响并请用户确认;绝不回显 secrets。
```
### gori-developer
```text
你是 Gori 开发 Assistant。说话像一位资深开发搭档:直接、清楚、有判断力;先说结论,再给关键改动或验证方式;不堆内部字段和协议词,不机械复读 ID。能确定就明确说,不能确定就直说;遇到风险改动、Git mutation、部署或共享状态变化时,用平实的话说明影响并请用户确认;绝不回显 secrets。
```
## QQ 发送边界
当前 QQ Bot 没有主动群消息权限,错误码:
```text
40034105 主动消息失败, 无权限
```
因此事件通知按方案 B:
- 群聊 worker event 优先使用最近入站消息的 5 分钟被动回复窗口;实现按 4.5 分钟保守判定。
- 私聊按 60 分钟窗口;实现按 55 分钟保守判定。
- 超过窗口不主动发;事件进入 pending,下次该 chat 有入站时补发。
- 无 `msg_id` 时不要发送裸 `msg_seq`。
- 同一 `msg_id` 的 `msg_seq` 必须由 Gateway 共享递增,正常回复、事件、补发不能各自从 1 开始。
- `/status` 应能看到 `schedulerState`、`blockedReason`、`lastEventDelivery`、`lastEventError`、`lastEventAt`。
出站图片同样没有主动权限,走同一被动窗口与共享 `msg_seq`:Worker 把给用户看的 png/jpg 存到 workspace 内(建议 `.gori-outbox/`)并在 result envelope 的 `attachments` 上报,runtime 校验 workspace containment、magic bytes、单张 ≤10MB、最多 3 张;发送时先 `POST /v2/{groups|users}/{id}/files` 上传(`file_type: 1` + base64 `file_data` + `srv_send_msg: false`)再 `msg_type: 7` + `media.file_info` 发送,群/私聊上传的 file_info 不通用。落定事件先发文案再发图;窗口过期补发时按路径重读文件,文件没了降级为文本说明;上传/发送失败不阻断文本。用户后来说「把图发给我」时 Assistant 用 `send_image` action 发同一张图。
## 验收清单
部署完成后至少检查:
```bash
git status --short --branch
bin/gori-agent status <bot-id>
```
并确认:
- runner PID 正常
- Health HTTP 200 且 identity verified
- QQ websocket connected/ready(若使用 websocket)
- 没有 working Proposal 被误重启
- Assistant/Worker Kimi config 均通过 `kimi doctor config`
- 日志中无新的 send failed、worker crash、assistant isolation violation
+215 -269
View File
@@ -1,353 +1,299 @@
# gori-agent 项目施工指南 # 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 ```text
用户 用户 → Platform Adapter → Gateway → AssistantManager(Assistant / Proposal / Worker)→ ACP Agent
→ QQ / 其他 IM
→ Platform Adapter
→ Gateway
→ AcpSessionManager
→ AcpWorker
→ ACP Coding Agent(默认 Kimi Code)
→ 工具、脚本和工作目录
→ 原路返回用户
``` ```
当前运行时只使用 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 和权限策略。 ## 2. 实例目录契约
- **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 使用多个进程和独立配置、端口、状态目录。
## 3. 代码结构 统一实例根:
- `src/server.ts` ```text
- 组装 store、role registry、ACP session manager、Gateway 和平台 adapter。 ${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances/<bot-id>/
- 挂载 health/platform/webhook 路由,按配置启动 QQ WebSocket。 ├── config.json
- `src/config.ts` ├── logs/gori-agent.log
- config v2 的 Zod schema、默认值、交叉引用校验和 v1 Kimi 配置迁移。 └── state/
- 新增配置字段时,必须先更新 schema,再更新模板、setup/doctor、测试和文档。 ├── acp-sessions.json
- `src/core/gateway.ts` ├── proposals.json
- 入站 allowlist、群聊 mention 规则、命令分发、per-chat 串行锁和回复发送。 ├── assistant-workspaces/
- `/cancel` 必须能绕过 chat lock,避免无法取消长任务。 ├── kimi/assistant/
- `src/core/command-router.ts` └── gori-agent.pid
- `/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/`。
## 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。
- 旧的 `/usr/bin/flock` workspace lease 与 `owner.json` 已退役,不要重新引入。同一 `GORI_AGENT_ROOT` 下由 workspace scope 兜底:`doctor`、外层 `start` 与内部 runner 扫描其他实例目录的完整 Config v3,任一目录/配置不可读或无效即 fail closed;canonical workspace 相同或互为父子一律拒绝。该机制不覆盖终端、IDE、其他 root 或未接入 gori-agent 的进程。
- `init` / `setup` 必须交互询问并校验 server host/port;扫描其他 Config v3 的声明端口,冲突时警告,新实例建议下一个未声明端口,已有配置不得静默改端口。
- `setup <bot-id>` 只重配已有实例:校验目录/config/PID,运行中或 foreign PID 拒绝,固定 Bot ID,保留 agent/skills/permissions/policy/runtime/secrets/publicBaseUrl,确认写入后运行 doctor。
- setup 的声明冲突提示不替代实际 bind 检查;`start` 对缺配置、ID 不匹配、错误权限、占位符、运行 PID、端口冲突 fail closed。
- start 只 spawn 构建后的内部 `dist/cli/instance-runner.js <config-path>`;PID identity 必须精确匹配 Node executable、runner 和唯一 config 参数。
公开入口仅有:
```bash ```bash
cp config.example.json config.json gori-agent init <bot-id>
chmod 600 config.json gori-agent setup <bot-id>
gori-agent start <bot-id>
gori-agent stop <bot-id>
gori-agent restart <bot-id>
gori-agent status <bot-id>
gori-agent logs <bot-id>
gori-agent doctor <bot-id>
gori-agent list
``` ```
`config.json` 和备份包含 IM credentials,不得提交。 不兼容旧 `instance` 前缀;不公开 debug 命令、`--config` 或 `--json`。
### 4.1 顶层字段 ## 3. 核心代码结构
- `src/config.ts`
- Config v3 Zod schema、类型、交叉校验和 state 默认路径。
- 只接受 `configVersion: 3`。
- `src/server.ts`
- 组装 fixed Bot、state store、ProposalStore、AssistantManager、Gateway 和唯一 platform adapter。
- 只挂载所选平台 route;创建 AssistantManager 时传入 `runtime.acp.maxAssistantSessions`。
- `src/roles/role-registry.ts`
- 历史路径名保留,但实现是 `BotProfileResolver`,不是 role registry。
- 加载当前 Bot skills、计算 fingerprint、生成版本化 assistant/worker bootstrap。
- Assistant 用 `bot.assistantPersona || bot.persona` 并带人话风格规则;Worker 始终用 `bot.persona`,bootstrap 保持严格。
- `src/acp/client.ts`
- ACP initialize/new/resume/load/prompt/cancel 和 permission request。
- assistant session 一旦出现 tool update 或 permission request 必须 fail closed。
- `src/acp/worker.ts`
- worker 在 `bot.workspace` 启动;assistant 在实例私有 `state/assistant-workspaces/<hmac>` 启动,未显式配置时 `KIMI_CODE_HOME` 指向实例私有 `state/kimi/assistant/`。
- 每个 ACP worker 使用独立进程组和随机 `GORI_AGENT_WORKER_TOKEN`;cancel、timeout、crash 或 assistant 隔离违约必须清理同进程组工具后代;bootstrap 禁止 `setsid`、`nohup`、detached/daemon/background 遗留进程,主动脱组属于无 cgroup/Bubblewrap 方案的边界。
- assistant 只接受 Kimi Code ACP,并依赖项目级 `tools: []`、`subagents: []` profile;permission deny 只是附加层。
- `src/acp/assistant-manager.ts`
- 每 conversation(chat + user)一个无工具 Assistant 会话,同群不同用户互相隔离(`GORI_ASSISTANT_ACTION_V2` envelope:create_proposal/confirm/adjust_proposal/follow_up/finish/send_image/start_next/cancel/stop,格式只修复一次);runtime 执行 action 后在 reply 末尾追加人话纠正(如 start_next 被阻塞、无 owner 匹配),Assistant 不得自行宣称 action 已生效。
- 唯一活跃 Worker 执行已确认 Proposal(`GORI_WORKER_RESULT_V2`:仅 `PENDING`,summary 必填,可带 question/workspaceDirty/attachments);pending 不区分 success/fail,只有 finish 落定为 finished(done),cancel 落定为 finished(cancelled),不自动开始下一个。
- envelope 解析先严格匹配(闭合标签 + 文本末尾 anchor);缺闭合标签时从起始标签后做花括号配平(字符串/转义感知)salvage,配平点必须在文本末尾才接受,否则仍判无效走修复。worker 启动(new/resume session)、envelope 无效、repair 成败、settle(attachments/dropped 数)、worker_error、follow_up resume/fresh 兜底均有单行安全日志(proposal id 前 8 位,不含正文)。
- capacity(maxAssistantSessions/maxProcesses)、idle sweep、cancel/confirm/finish/stop、worker_lost 恢复、owner 事件通知。
- `src/core/durable-session-store.ts`
- state v3 只保存 bot/platform identity 和 assistant binding(conversation key,即 chat + user、agent ID、native session ID、assistant workspace、fingerprint、时间戳、`lastActiveAt`);不保存消息正文。
- 单 writer lock、串行持久化、临时文件、fsync、原子 rename;version/identity 不匹配(含旧 v1/v2)拒绝并保留原文件。
- `src/core/proposal-store.ts`
- proposals.json(version 2)保存 Proposal:title/goal/steps、owner chat、发起用户 requesterUserId、状态流(proposed/queued/working/pending/finished)、pending(summary/question?/workspaceDirty?/receivedAt)、finished(finishKind done|cancelled、finishNote?)、worker session 与进程组 PGID/token;同样的 lock 与原子写入纪律。打开 v1 文件时先写 `proposals.json.v1-<timestamp>.bak`(0600)备份再按固定映射迁移。
- `src/core/workspace-scope.ts`
- canonical workspace + 跨实例 Config v3 扫描;相同或父子 workspace 在 doctor/start/runner fail closed。lease 已退役。
- `src/core/workspace-images.ts`
- 出站图片校验与读取:路径必须 resolved 在 canonical workspace 内、png/jpg magic bytes、单张 ≤10MB;发送时按路径重读。
- `src/core/gateway.ts`
- 入站 allowlist、群 mention、命令和回复;不维护普通消息队列或 per-chat task lock,也没有固定时间处理中提醒。
- 每条异步入站使用独立串行 ReplyStream,`replySequence` 从 1 动态递增;发送失败只写安全日志且不阻止任务或后续发送。
- Worker 落定事件经 `sendEvent` 发送:优先引用该 chat 最近入站消息走被动回复窗口(群 4.5 分钟、C2C 55 分钟保守判定,`msg_id` + 递增 `msg_seq`);`msg_seq` 按 chat+messageId 共享计数(正常回复、事件、补发同一计数器,QQ 重复推送同一 msg_id 时沿用已用序号);无新鲜窗口或发送失败时不发主动消息,记录 lastEventDelivery/lastEventError/lastEventAt,补发失败保留队列并在下次入站时重试。
- `src/core/command-router.ts`
- `/help`、`/status`、`/list`、`/confirm`、`/finish`、`/stop`、`/cancel`;未识别命令按普通消息处理。
- `src/platforms/*`
- Adapter 依赖独立平台 config type,不依赖完整 AppConfig 路径。
- `src/cli/config-file.ts`
- fail-closed 加载、example seed 与原子 `0600` 配置写入。
- `src/cli/instance.ts`
- 实例目录、PID/log、端口和 lifecycle 管理。
- `src/cli/instance-runner.ts`
- 非公开进程入口;加载单实例配置、启动 server,并处理 SIGINT/SIGTERM 优雅关闭。
- `src/cli/doctor.ts`
- v3、Bot、agent、platform、state、permission 和 placeholder 检查。
- `src/agents/*`、`src/core/session-store.ts`
- legacy compatibility/reference,不是当前运行链路。
- `dist/`
- 构建产物;不手改,修改 TypeScript 后运行 `npm run build`。
## 4. Config v3 契约
仓库中唯一可提交配置说明是 `config.example.json`。它含明显占位符,可通过 schema,但不能启动实际 Bot。
顶层:
```text ```text
configVersion 必须为 2 configVersion 固定 3
server HTTP 服务配置 bot 固定 Bot、agent、skills、permissions
policy IM 入站访问策略 gateway server、入站 policy、唯一 platform
acp ACP 生命周期、状态和进程池配置 runtime.acp state 和 ACP 生命周期
backends[] ACP 后端启动定义,至少一个
skills[] 可选 SKILL.md 定义
defaultRole 默认 role ID
roles[] 业务角色定义,至少一个
platforms QQ、Feishu、WeCom、Webhook、Weixin 配置
``` ```
Zod 会剥离 schema 未声明的字段。因此不能只在 JSON 中增加字段而不修改 `src/config.ts`。 ### 4.1 `bot`
### 4.2 `server`
- `host`:默认 `0.0.0.0`。
- `port`:1–65535;多实例必须使用不同端口。
- `publicBaseUrl`:反向代理或公开 webhook 基础地址;不需要时为空。
### 4.3 顶层 `policy`
- `allowedUsers: string[]`:空数组表示不按用户限制;非空时精确匹配。
- `allowedChats: string[]`:空数组表示不按会话限制;非空时精确匹配。
- `requireMentionInGroup: boolean`:群聊是否必须 @bot。
- QQ 群 chat ID 形式为 `group:<group_openid>`。
- 正式运维 Bot 应配置 allowlist,不要长期保持完全开放。
### 4.4 `acp`
- `stateFile`:空字符串时使用 `$GORI_AGENT_HOME/state/acp-sessions.json`。
- `initializeTimeoutMs`:ACP 初始化超时。
- `promptTimeoutMs`:单轮 prompt 超时。
- `cancelGraceMs`:取消后等待时间,超时才终止 worker。
- `idleTimeoutMs`:空闲 worker 回收时间;session binding 仍可持久化恢复。
- `sweepIntervalMs`:空闲扫描周期。
- `maxProcesses`:最大 ACP 子进程数。
不同实例不得共享 `stateFile` 或 `GORI_AGENT_HOME`。持久 store 有单 writer lock,共享会使第二个实例启动失败。
### 4.5 `backends[]`
每项字段:
```json ```json
{ {
"id": "bot-id",
"workspace": "/absolute/path",
"persona": "明确职责、边界和确认点。",
"assistantPersona": "可选;Assistant 讲话人格,为空时回退 persona。Worker 始终用 persona。",
"agent": {
"id": "kimi", "id": "kimi",
"command": "/home/USER/.kimi-code/bin/kimi", "command": "/absolute/path/to/kimi",
"args": ["acp"], "args": ["acp"],
"env": {} "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": "明确描述职责、边界和何时停止请求确认。",
"skills": [], "skills": [],
"policy": { "permissions": {
"permissionMode": "deny", "mode": "deny",
"allowedTools": [], "allowedTools": [],
"allowedCommandPatterns": [] "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。 ### 4.2 Skills
- `workspace` 必须是绝对路径,并应在 `doctor` 时真实存在。
- `backend` 和 `skills` 引用必须存在。
- 修改 role ID、backend、workspace、persona、policy 或 skill 内容会改变 fingerprint;下一条消息会创建新 native session,避免沿用旧身份上下文。
权限模式: `bot.skills[]` 每项直接声明:
- `deny`:拒绝所有 ACP permission request;新角色默认使用此模式。
- `allowlist`:只允许 `allowedTools`;bash/terminal 还必须匹配至少一个 `allowedCommandPatterns` 正则。
- `auto`:自动允许,等价于高风险/yolo;只有用户明确要求、workspace 与 Bot 访问范围都可控时才能配置。
配置 `allowlist` 时:
- 使用最小工具集合。
- 命令正则应锚定开头并限制参数,不要使用 `.*` 放行所有命令。
- 删除、部署、推送、发送消息等不可逆或外部操作仍应在 persona 中要求确认。
- permission request 信息不足时必须拒绝,不能猜测。
### 4.8 `platforms.qq`
```json ```json
{ { "id": "skill-id", "file": "/absolute/SKILL.md", "maxBytes": 256000 }
"enabled": true,
"connectionMode": "websocket",
"appId": "QQ_APP_ID",
"clientSecret": "QQ_CLIENT_SECRET",
"botSecret": "",
"verifySignature": true,
"botNames": ["QQ_BOT_NAME"],
"intents": 33554432,
"shard": [0, 1]
}
``` ```
- WebSocket 登录需要有效的 `appId` 和 `clientSecret`。 ID 唯一;文件须存在、可读、普通文件且未超限。skill 内容发送给 ACP,也进入 fingerprint;不得放 secret。
- Webhook 验签使用 `botSecret || clientSecret`。
- `botNames` 用于 mention 识别,应与平台上的机器人名称一致。
- 一个 config 只有一个 QQ 对象,因此一个进程只能登录一个 QQ Bot。
- 不要把 QQ 注销、群注销、无权限等平台 4xx 错误误判成 ACP session 错误;先检查错误码、目标 chat/user openid 和消息发送 endpoint。
其他平台字段以 `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`。 permission policy 只审批 ACP request,不会注册工具。persona 不是安全边界。请求信息不足时拒绝。命令 regex 应锚定并限制参数,不使用任意 `.*` 全放行。
- 同一 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。
## 6. 多机器、多 Bot 部署规范 ### 4.4 Gateway
每个 Bot 使用一份位于部署机器上的本地 `config.json`,不要在仓库中创建 `config.ops.json`、`config.developer.json` 等带真实凭据的文件。 - `gateway.server`: host、port、publicBaseUrl。
- `gateway.policy`: allowedUsers、allowedChats、requireMentionInGroup。
- `gateway.platform`: `type` discriminated union,只能是 qq、feishu、wecom、webhook、weixin 之一。
- 对象存在即启用,无 `enabled` 字段。
每个实例至少要唯一: QQ 群 chat ID 为 `group:<group_openid>`;用户 ID 按 QQ 官方语义取值:群聊作者用 `member_openid`,C2C 私聊作者用 `user_openid`。正式运维 Bot 应配置入口 allowlist。
- QQ `appId`、`clientSecret`、`botNames` ### 4.5 Runtime ACP
- `server.port`
- `GORI_AGENT_HOME`,或显式且独立的 `acp.stateFile`
- role ID、workspace、persona 和 policy
示例: - `stateFile` 为空时为 `$GORI_AGENT_HOME/state/acp-sessions.json`;proposals 固定在同目录 `proposals.json`。
- `initializeTimeoutMs`: 默认 10000。
- `promptTimeoutMs`: 默认 14400000(4 小时)。
- `cancelGraceMs`: 默认 5000。
- `idleTimeoutMs`: 默认 1800000。
- `assistantSessionResetIdleMs`: 默认 3600000;`0` 禁用。Assistant 会话(按 binding 的 `lastActiveAt`,每轮刷新并持久化)空闲超过该阈值且该 owner(chat + user)没有任何 `proposed/queued/working/pending` Proposal 时,下一条消息删除 binding 开新 session(新 HMAC workspace 目录),而不是 resume 旧 session;有未完成 Proposal 则照常 resume。重置只在下一条入站消息时 lazy 发生,sweeper 不主动删 binding。
- `sweepIntervalMs`: 默认 60000。
- `maxProcesses`: 默认 8,包含 assistant + worker。
- `maxAssistantSessions`: 默认 4。
```bash Kimi Code agent `args` 必须严格为 `["acp"]`,不能添加可能绕过 assistant no-tool profile 的启动参数。
GORI_AGENT_HOME=/home/USER/.gori-agent-ROLE_ID \
./gori-agent.sh start-daemon --config ./config.json
```
`GORI_AGENT_HOME` 隔离: ## 5. Assistant / Proposal / Worker 与 state v3
- PID 运行链路是三层:每 conversation(chat + user)一个无工具 Assistant 会话负责对话与创建 Proposal;唯一活跃 Worker 在 `bot.workspace` 执行已确认 Proposal;Proposal 是两者之间的持久工作单,owner 是 chat + 发起用户。`confirm` / `adjust` / `follow_up` / `start_next` 维持 owner-only(runtime 强校验);为了避免单人把单 worker 队列卡死,`finish` / `stop` / `cancel` 是全板共享动作,任何用户都可对可见 proposal 执行。Proposal 板全局共享可见:Assistant prompt 的全板列表包含所有 chat/user 的未完成条目(`scope=own` / `scope=other` + chat 类型,不暴露 openid),Assistant 可向任何用户如实描述全板,并可按规则对共享动作发起 action。
- 日志
- 默认 ACP session state
- 单实例锁
同一机器启动多个实例时,每个实例应从各自部署目录运行,或明确指定独立配置路径和 `GORI_AGENT_HOME`。 Proposal 状态流:`proposed → queued → working → pending → finished`。`proposed` 必须用户确认才进入 `queued`;proposal 一旦进入 `working`,worker 默认连续执行普通低风险步骤,不为工作区内读写、搜索、本地构建或测试逐步回问,只有遇到高风险动作、关键业务选择、外部阻塞或 dirty/异常目标时才返回 `pending question`;`pending` 就是「等用户决定」,不区分 success/failure;只有 `finish` 把 pending 落定为 `finished(done)`,`cancel`(proposed/queued/pending)落定为 `finished(cancelled)`;没有 working、owner 自己没有 pending、全局没有 workspaceDirty pending 时,最早确认的 queued Proposal 才可通过 confirm 或 start_next 开始。
## 7. 配置施工流程 Assistant 每条回复以隐藏 `GORI_ASSISTANT_ACTION_V2` envelope 结尾(`reply` + `actions`),action 仅 `create_proposal`、`confirm`、`adjust_proposal`(仅 proposed/queued)、`follow_up`(pending → working,优先 resume 原 worker native session,失败则带 Proposal 上下文新起 session,用户图片附件随 prompt 给 Worker)、`finish`、`send_image`(把 Worker 报告过的 workspace 内图片发给用户)、`start_next`、`cancel`、`stop`,格式错误只修复一次。Worker 每轮以隐藏 `GORI_WORKER_RESULT_V2` envelope 收尾:仅 `PENDING`(`summary` 必填,可选 `question`、`workspaceDirty`、`attachments`),同样只修复一次。envelope 缺闭合标签(模型截断)时先按花括号配平 salvage(字符串/转义感知,配平点必须在文本末尾),失败才进入修复流程。
当用户让 Agent 配置一个新角色或新 Bot 时,按以下顺序执行: 确认语义:
1. 读取 `AGENTS.md`、`config.example.json` 和相关 schema;不要先读取或展示真实 secrets。 - Worker 落定即进入 `pending`(记录 summary/question?/workspaceDirty?/receivedAt);`finish`(可带 note)落定为 `finished(done)`,`cancel` 落定为 `finished(cancelled)`;finished 保留最后 summary 供面板展示。
2. 明确 role 的职责、workspace、所需 skills、允许的工具和命令。 - 系统不自动开始下一个 Proposal;只有新确认或 `start_next` 推进队列。
3. 确认 backend 可执行路径以及 `kimi acp` 可用。 - 阻塞规则:任何 `working` 全局拒绝 start_next/follow_up;owner 自己有 pending 时拒绝该 owner 的 start_next(提示先 finish 或 follow_up);任何 `workspaceDirty: true` 的 pending 全局拒绝 start_next(提示先处理 dirty 工作区);非 dirty 的 pending 不挡其他用户。
4. 从唯一模板复制本地 `config.json`,不另建可提交的具体 Bot 模板。 - `stop` 只对 working 生效:停 worker(现有进程组清理)后 Proposal 转为 pending(summary「被用户中止」、workspaceDirty)。
5. 填写本机 QQ credentials;不要在回复、日志、diff 或测试输出中回显。 - Assistant 有 pending 时每轮 prompt 注入 pending card;回复没提到 pending 时 runtime 在 reply 末尾追加人话兜底提醒。
6. 默认先用 `deny`;需要施工能力时配置最小 `allowlist`;只有明确授权才使用 `auto`。 - Worker working 期间 runtime 按类别聚合 worker 的 `session/update` 工具活动(turn 开始重置计数;只留类别/时间,不留参数与正文);Assistant prompt 注入紧凑状态卡(title、mm:ss 时长、最后活动类别与距今、各类别计数、快照指引)。调试期状态卡对所有人可见(他人任务标注 `another user's proposal`),正式运营时应恢复 owner-only。
7. 设置文件权限为 `0600`。 - Gateway 无固定时间提醒;worker 落定后由 Assistant 生成事件文案发给 owner chat,QQ 无主动消息权限时优先使用被动回复窗口(群 5 分钟/私聊 60 分钟,按 4.5/55 分钟保守判定),过期或失败则不主动发、记录 lastEventDelivery 并在下次入站时补发;`/status` 输出当前 chat 的事件投递状态与 schedulerState/blockedReason/nextAction。
8. 运行配置和 backend 检查:
```bash Assistant 隔离:cwd 在实例私有 `state/assistant-workspaces/`,目录 key 使用进程随机 salt 的 HMAC,不得与项目 workspace 重叠;Kimi 项目级 agent override 必须设置 `tools: []`、`subagents: []`,ACP `mcpServers: []`,未显式配置时 `KIMI_CODE_HOME` 指向实例私有 `state/kimi/assistant/`;任何 tool update 或 permission request 都视为隔离违约,fail closed、终止进程组并删除 binding。每个 ACP worker 独立进程组 + 随机 token;runner 重启时 working Proposal 校验 token 清理旧进程组后标记为 pending(worker_lost、workspaceDirty: true)。
./gori-agent.sh discover-backends --config ./config.json
./gori-agent.sh doctor --config ./config.json
```
9. 前台启动,确认 QQ WebSocket ready,再从 IM 执行只读验收。 state v3(`acp-sessions.json`)header 保存 `botId` 和 platform,只存 assistant binding(agent ID、assistant workspace、native session ID、Bot fingerprint、时间戳);`proposals.json`(version 2)存 Proposal 记录。state v3 打开时 version/identity 不匹配必须拒绝,不能清空、迁移或覆盖;旧 state v1/v2 也明确拒绝并保留原文件。proposals.json 唯一例外:v1 文件打开时先在同目录写 `proposals.json.v1-<timestamp>.bak`(0600)备份,再按固定映射迁移为 v2(SUCCESS → pending{summary};FAILED → pending{summary, workspaceDirty: true};NEEDS_CONFIRMATION → pending{summary, question};completed/failed → finished(done)(failed 保留失败说明为 finishNote);cancelled → finished(cancelled);proposed/queued/working 保留)。fingerprint 包含 bootstrap schema version、Bot ID、workspace/persona/assistantPersona、agent 定义、permission policy、skill 路径和内容 hash。prompt 失败不重放。
10. 前台通过后,使用唯一 `GORI_AGENT_HOME` 后台启动。
11. 任何包含 secret 的配置、备份和日志都不得加入 Git。
若缺少真实凭据,可以完成无密钥模板和规范,但不得虚构 credentials,也不得声称真实 QQ 登录已验证。 命令 `/help`、`/status`、`/list`、`/confirm`、`/finish`、`/stop`、`/cancel` 旁路 Assistant:`/confirm` 仍只对 owner 的 proposed 生效;`/finish` 对任意可见 pending 生效;`/stop` 对任意可见 working 生效(→pending);`/cancel` 对任意可见 proposed/queued/pending 生效;`/list` 显示全局板(含最近 finished),按 pending → working → queued → proposed → 最近 finished 排列,自己的条目标注「你的」,他人的标注「其他成员」(不暴露 ID)。pending card 每轮强提醒与兜底追加维持 owner-only。QQ 入站图片附件(image/*,单张 ≤5MB、每条最多 3 张、10 秒下载超时)下载为 base64 经 `IncomingMessage.attachments` 透传;agent 声明 `promptCapabilities.image` 时作为 ACP image content block 发给 Assistant/Worker,否则降级为文本说明;视频/文件附件不下载,仅以 `[视频] <url>` / `[文件] <url>` 文本拼接。
## 8. 常用命令 出站图片(QQ):Worker 在 `GORI_WORKER_RESULT_V2` 的 `attachments`(最多 3 个 `{path, mimeType?}`)上报 workspace 内 png/jpg(建议 `.gori-outbox/`),Assistant 也可用 `send_image { path }` 主动发图;runtime 校验 resolved realpath 必须在 canonical workspace 内、png/jpg magic bytes、单张 ≤10MB,违规丢弃并在事件文本说明。发送走 `POST /v2/{groups|users}/{id}/files` 上传(`file_type: 1` + base64 `file_data` + `srv_send_msg: false`)后 `msg_type: 7` + `media.file_info` 发送;群/私聊上传的 file_info 不通用,按目标分别上传。图片与文本共用 Gateway 的 `msg_id` + 递增 `msg_seq` 计数器;落定事件先文案后图;被动窗口过期时照旧记录并下次入站补发,补发按路径重读文件、文件缺失降级为文本说明;上传/发送失败不阻断文本,降级文本说明 + 安全日志。其他平台 adapter 无 `supportsImages` 标记时图片降级为 `[图片] <文件名>` 文本。
```bash ## 6. 单平台装配
npm install
npm run typecheck
npm test
npm run build
gori-agent setup --config ./config.json Server 只构造 `gateway.platform.type` 对应 adapter,只挂载对应 webhook:
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 - qq webhook 模式 → `/webhook/qq`;QQ WebSocket 模式不挂载该 route
./gori-agent.sh start-daemon --config ./config.json - feishu → `/webhook/feishu`
./gori-agent.sh status --config ./config.json - wecom → `/webhook/wecom`
./gori-agent.sh logs - weixin → `/webhook/weixin`
./gori-agent.sh stop - webhook → `/webhook/generic`
```
`gori-agent.sh` 不会检测源码是否比 `dist/` 新。修改 TypeScript 后必须显式运行 `npm run build`。 QQ WebSocket 仅在 qq + websocket 模式启动。`GET /health` 只输出 configVersion、botId、platform 和 aggregate ACP counters,不得输出 credentials 或 native session ID。`GET /platforms` 返回单 Bot/platform 概览。
## 9. 修改与验收规则 ## 7. 配置与实例施工流程
1. 读取本文件、`config.example.json` 和 `src/config.ts`。
2. 明确 Bot ID、workspace、persona、平台、skills 和最小 permission policy。
3. 确认 ACP agent executable 与 ACP 可用。
4. 使用 `gori-agent init <bot-id>` 或 Coding Agent 生成实例 `config.json`;不要把 example 当运行配置。
5. 交互确认并严格校验 `gateway.server.host/port`;对其他实例的声明端口冲突给出警告和建议,但仍由 `start` 做实际 bind 检查。
6. 写配置必须同目录临时文件 + fsync + atomic rename,最终 `0600`;目录 `0700`。
7. setup 的 secret/token 输入必须不回显;secrets 不在对话、日志、测试输出、diff 中回显。
8. 运行 `gori-agent doctor <bot-id>`,不发送真实平台消息。
9. 只有用户明确授权时才启动/停止真实 Bot。
旧仓库本地 `config.json`、备份和 state 可供人工回退;改造实例时不要删除或覆盖。
## 8. 测试与验收
项目级部署/配置 skill 位于 `.kimi-code/skills/gori-agent-deploy/SKILL.md`;真实 Bot 部署、模型/persona 配置和 state 备份流程以该文件为准。
行为变化补测试,不削弱测试。配置 schema 变化同步:
- 做最小、局部、可审查的修改,不做无关重构。
- 先修改 `src/`,不要直接修改 `dist/`。
- 行为变化应补对应测试;不要通过削弱测试来适配实现。
- 配置 schema 变化必须同步:
- `src/config.ts` - `src/config.ts`
- `config.example.json` - `config.example.json`
- setup/doctor(如适用) - setup/doctor/instance CLI
- README 和本文件 - README 和本文件
- 配置测试 - config、session、store、Gateway、server 测试
- 平台错误必须保留足够的错误码和 trace ID 用于诊断,但不得输出 secret。
- 修改后运行: 最终运行:
```bash ```bash
npm run typecheck npm run typecheck
npm test npm test
npm run build npm run build
git diff --check git diff --check
bash -n install.sh gori-agent.sh bin/gori-agent
``` ```
- 测试未通过时不能宣称完成。 额外检查:
- 不执行真实 QQ 消息、pull、部署、push、创建 PR 等外部操作,除非用户明确要求并授权。
- 不自动执行 Git commit/push;需要时按用户当次指令处理。
## 10. 敏感信息与禁止提交内容 - example 可 parse,doctor 识别 placeholder。
- 不存在的运行配置明确失败。
- tracked diff 无 config、backup、state、log、credentials 或手改 dist。
- 不执行真实平台消息、pull、部署、远程机器修改或 Git mutation,除非用户当次明确授权。
## 9. 敏感信息与禁止提交
不得提交或粘贴: 不得提交或粘贴:
- `config.json` - 任何真实 `config.json` 或备份
- `config.json.*.bak` - `.env`、agent env token/API key
- 任意包含真实 Bot/App credentials 的自定义配置 - platform credentials
- `.env` - ACP state、PID、lock、session metadata
- `backends[].env` 中的 token/API key - 含真实 chat/user/message ID 或消息正文的日志
- ACP state、PID、lock 和 session metadata
- 日志中的真实 chat ID、user ID、message ID、消息正文或 ACP stderr 敏感内容
唯一可提交配置模板:`config.example.json`。其中只能使用明显占位符。 `.gitignore` 已覆盖仓库本地 `config.json`、备份、日志、`dist`、`node_modules` 和 coverage。实例默认在仓库外。安全性按内容判断,不能只依赖文件名。
当前 `.gitignore` 已覆盖 `config.json`、备份、日志、`dist/`、`node_modules/` 和 coverage。新增其他具体配置文件名时,不要依赖命名约定判断安全;只要包含真实凭据,就必须留在仓库外或明确忽略。
+225 -285
View File
@@ -1,16 +1,50 @@
# gori-agent # gori-agent
通过 QQ 等 IM 调用 ACP Coding Agent。当前默认后端是 Kimi Code: `gori-agent` 是一个 Node.js 20+ / TypeScript 网关:从一个 IM 平台接收消息,通过官方 Agent Client Protocol(ACP)驱动一个 Coding Agent。
```text ```text
用户 → QQ → gori-agent Gateway → ACP → Kimi Code → 工具/脚本 → QQ 用户 → 单个平台 Adapter → Gateway → AssistantManager(Assistant / Proposal / Worker)→ 单个 ACP Agent
``` ```
## 五分钟上手 ## Config v3 实例模型
### 1. 准备 Kimi Code Config v3 只支持以下边界:
确认 Kimi Code 已登录且 ACP 可用: - 一份运行配置对应一个固定 Bot 身份。
- 一个 Bot 只有一个 workspace、persona、ACP agent、skill 列表和 permission policy。
- 每个 chat 一个无工具 Assistant 会话;它只对话、创建 Proposal 并解释 Worker 反馈,自己从不执行。
- 同一时刻全实例只有一个 Worker 在 `bot.workspace` 执行一个已确认 Proposal;Worker 每轮只交回 result(pending),由用户决定 finish 结束还是继续说要求继续,系统不会自动开始下一个 Proposal。
- 一个实例只绑定一个平台;多平台使用多个实例。
- 不再有 `roles[]`、`defaultRole`、`backends[]`、动态 `/role` 切换、单主任务/近似 BTW 或 Config v1/v2 迁移。
- `configVersion` 非 `3` 会明确失败,旧 state v1/v2 也不会自动改写。
一台机器上的实例统一位于:
```text
${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances/<bot-id>/
├── config.json
├── logs/gori-agent.log
└── state/
├── acp-sessions.json # state v3:Assistant binding
├── proposals.json # Proposal 记录
├── assistant-workspaces/ # 每 chat 私有 Assistant cwd
├── kimi/assistant/ # Assistant 私有 KIMI_CODE_HOME
└── gori-agent.pid
```
实例目录就是该进程的 `GORI_AGENT_HOME`。实例目录、`state/`、`logs/` 使用 `0700`,配置与状态文件使用 `0600`。
## 构建与安装
```bash
npm install
npm run build
./install.sh
```
安装器只安装一个 launcher;不会为每个 Bot 复制源码或 `dist/`。默认 launcher 位于 `$HOME/.gori-agent/bin/gori-agent`。
确认 ACP agent 可用:
```bash ```bash
kimi --version kimi --version
@@ -18,331 +52,237 @@ kimi doctor
kimi acp --help kimi acp --help
``` ```
ACP 使用 `~/.kimi-code/config.toml` 中的 `default_model`。模型切换应先在 Kimi Code 中完成,再重启 gori-agent;已有聊天要使用新模型时,在 QQ 中发送 `/new` 创建新 session。 模型和 provider 由 ACP agent 自身配置,例如 Kimi Code 使用 `~/.kimi-code/config.toml`。gori-agent 不复制模型凭据。
### 2. 构建并迁移配置 ## 实例管理
```bash ```bash
cd /home/ubuntu/gori-space/gori-agent gori-agent init <bot-id>
npm install gori-agent setup <bot-id>
npm run build gori-agent doctor <bot-id>
./gori-agent.sh setup --config ./config.json gori-agent start <bot-id>
gori-agent status <bot-id>
gori-agent logs <bot-id>
gori-agent restart <bot-id>
gori-agent stop <bot-id>
gori-agent list
``` ```
建议向导选择: - `init` 交互生成 Config v3,本身不启动 Bot;它会询问并校验 `gateway.server.host/port`,扫描统一实例目录中其他 Config v3 的声明端口,并在默认端口冲突时建议下一个未占用端口。
- `setup` 只重配已有实例。它校验目录、配置身份、配置/PID 权限,并拒绝运行中实例或指向其他活进程的 PID;固定 `bot.id`,保留既有 agent(含 args/env)、skills、permissions、gateway policy、runtime、平台 secrets 与 publicBaseUrl,确认写入后自动运行 doctor。
- 平台 secret/token 使用不回显输入,空 generic webhook/weixin secret 会随机生成。
- `start` 会检查配置存在、目录名匹配 `bot.id`、目录/配置权限、模板占位符、workspace、agent executable、PID identity 和端口,并等待 `/health` 返回匹配的 Bot/platform identity;任一不满足即 fail closed。
- 实例进程由内部 `dist/cli/instance-runner.js` 承载;`status` 和 lifecycle 命令精确核对 Node executable、runner 路径及唯一 config 参数,再核对 `/health` 的 Bot/platform identity。
- 每个实例必须使用不同的 `gateway.server.port` 和平台凭据;setup 的声明冲突提示不替代 `start` 的实际端口检查。
- `stop` 发送 `SIGTERM`,runner 会优雅关闭 server,并等待最多 10 秒;不会自动 `SIGKILL`。
公开 CLI 不兼容旧 `instance` 前缀,也不提供 debug 命令、`--config` 或 `--json`。
可用 `GORI_AGENT_ROOT` 改变统一根目录:
```bash
GORI_AGENT_ROOT=/srv/gori-agent gori-agent list
```
## `config.example.json` 的定位
`config.example.json` 是仓库内唯一可提交、无密钥的配置说明,不是运行配置。它可被 Config v3 schema 解析,但保留 `BOT_ID`、`QQ_APP_ID` 等明显占位符。只有 `init` 会读取 example 作为初始化种子;其他命令只按 `<bot-id>` 加载统一实例目录中的 `config.json`,缺失即失败。
`writeConfigFile()` 使用同目录临时文件、`fsync` 和原子 rename,并强制最终文件为 `0600`。
## Config v3
完整说明见 `config.example.json`。顶层只有:
```text ```text
Assistant workspace: /home/ubuntu/gori-space/gori-agent configVersion 固定为 3
Include ops role with the existing gori-update skill: yes bot Bot、workspace、persona、assistantPersona、agent、skills、permissions
Write config: yes gateway HTTP server、入站 policy、唯一 platform
runtime.acp ACP state、timeout 和 worker pool
``` ```
迁移会把旧的 `kimi -p` 改为 `kimi acp`,并完整保留已有 QQ appId、secret 和连接模式。 ### Bot 与 ACP agent
### 3. 检查配置
```bash
./gori-agent.sh discover-backends --config ./config.json
./gori-agent.sh doctor --config ./config.json
```
预期看到 Kimi ACP `ready`,并支持 session `load/resume`。
### 4. 前台启动并从 QQ 验收
```bash
./gori-agent.sh start --config ./config.json
```
启动日志应包含:
```text
QQ websocket connected
QQ websocket ready
```
在 QQ 群中 @bot 并发送:
```text
/status
/roles
/role assistant
只读回答当前工作目录,不要修改文件
```
运维角色先做只读测试:
```text
/role ops
/status
请只读取并概述 gori-update skill,不要 pull、构建或部署
```
常用聊天命令:
```text
/roles 列出角色
/role <id> 切换角色
/status 查看当前 role/workspace/session 状态
/cancel 取消正在执行的任务
/new 为当前角色创建新的 Agent session
```
同一个 `平台 + chat + role` 会持续使用同一个 Kimi session;Gateway 或 ACP 子进程重启后会恢复。不同 role 的 session 相互独立。
### 5. 后台运行
前台验收通过后:
```bash
export GORI_AGENT_HOME=/home/ubuntu/.gori-agent
./gori-agent.sh start-daemon --config ./config.json
./gori-agent.sh status --config ./config.json
./gori-agent.sh logs
./gori-agent.sh stop
```
运行状态和日志位于:
```text
/home/ubuntu/.gori-agent/state/
/home/ubuntu/.gori-agent/logs/gori-agent.log
```
`config.json` 包含 IM secret,不要提交,建议执行:
```bash
chmod 600 config.json
```
## 多机器与多 QQ Bot
仓库只维护一个无密钥模板:`config.example.json`。在每台机器、每个 Bot 实例中复制后单独修改:
```bash
cp config.example.json config.json
chmod 600 config.json
```
每个进程只能登录一个 QQ Bot。为每个实例配置唯一的 QQ `appId`、`clientSecret`、`botNames` 和 `server.port`,并使用不同的 `GORI_AGENT_HOME`,避免日志、PID 和 ACP session 状态相互覆盖:
```bash
GORI_AGENT_HOME=/home/USER/.gori-agent-ROLE_ID \
./gori-agent.sh start-daemon --config ./config.json
```
建议每个 Bot 的 `roles[]` 只保留它自己的一个 role,同时修改 `ROLE_ID`、`workspace`、`persona` 和 `policy`。需要 skill 时,再向顶层 `skills[]` 添加对应 SKILL.md,并在 role 的 `skills` 中引用。各机器的 `config.json` 不要提交到仓库。
## 配置角色
角色定义在 `config.json` 的 `roles[]` 中:
```json ```json
{ {
"id": "ops", "bot": {
"backend": "kimi", "id": "my-bot",
"workspace": "/home/ubuntu/gori-space", "workspace": "/absolute/workspace",
"persona": "你是 Gori 团队运维角色,严格遵循 gori-update skill。", "persona": "Describe responsibilities, boundaries, and confirmation points.",
"skills": ["gori-update"], "assistantPersona": "Optional speaking personality for the Assistant; falls back to persona when empty.",
"policy": { "agent": {
"permissionMode": "allowlist", "id": "kimi",
"allowedTools": ["read", "grep", "glob", "bash"], "command": "/home/USER/.kimi-code/bin/kimi",
"allowedCommandPatterns": ["允许的命令正则"] "args": ["acp"],
} "env": {}
}
```
- `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": { "skills": [],
"qq": { "permissions": {
"enabled": true, "mode": "deny",
"allowedTools": [],
"allowedCommandPatterns": []
}
}
}
```
- `bot.id` 只允许小写字母、数字和连字符,最长 63 字符。
- `workspace` 与每个 skill `file` 必须是绝对路径。
- Worker bootstrap 始终使用 `bot.persona`;Assistant 使用 `bot.assistantPersona`,为空时回退到 `bot.persona`。`assistantPersona` 只决定 Assistant 的讲话人格,可让运维 Bot 的 Assistant 说人话而 Worker 保持严格。
- `bot.skills[]` 直接声明 `{ id, file, maxBytes }`;ID 必须唯一。
- agent 使用 `shell: false` 在 `bot.workspace` 启动。
- agent env 可能包含 secret,不能进入可提交模板、日志或 diff。
### Permission policy
ACP permission request 由 `bot.permissions` 决策,persona 不是安全边界:
- `deny`:拒绝所有 permission request,默认值。
- `allowlist`:`toolCall.name` 必须与 `allowedTools` 精确匹配;bash/terminal 还必须由至少一个 `allowedCommandPatterns` 正则完整覆盖整条 raw command。名称或 command 缺失时拒绝。
- `auto`:自动允许,风险等价于 yolo;`doctor` 会告警。
`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", "connectionMode": "websocket",
"appId": "你的 AppID", "appId": "QQ_APP_ID",
"clientSecret": "你的 ClientSecret", "clientSecret": "QQ_CLIENT_SECRET",
"botSecret": "", "botSecret": "",
"verifySignature": true, "verifySignature": true,
"botNames": ["你的机器人名称"], "botNames": ["QQ_BOT_NAME"],
"intents": 33554432, "intents": 33554432,
"shard": [0, 1] "shard": [0, 1]
} }
}
}
``` ```
初次测试可让 `allowedUsers` 和 `allowedChats` 为空;正式运维 bot 应限制到指定用户或群。群聊 chat ID 的格式为 `group:<group_openid>`。 正式 Bot 应通过 `gateway.policy.allowedUsers` / `allowedChats` 限制入口。QQ 群 chat ID 为 `group:<group_openid>`;用户 ID 按 QQ 官方语义取值:群聊作者用 `member_openid`,C2C 私聊作者用 `user_openid`。
--- QQ 入站附件:`image/*` 附件会被下载(每条消息最多 3 张、单张超过 5MB 跳过、10 秒下载超时、缺 scheme 的 URL 自动补 `https:`)并转成 base64 随消息传给 Agent;agent 声明 `promptCapabilities.image` 时作为 ACP image content block 发送,否则降级为文本说明。视频/文件等非图片附件不下载,仅以 `[视频] <url>` / `[文件] <url>` 文本拼进消息,交给模型自由使用;纯图片消息使用占位文本「(发来一张图片)」。日志只记录附件类型/大小/数量,不记录 URL 全文或 base64。
## Detailed reference QQ 出站图片:Worker/Assistant 报告的 workspace 内图片(png/jpg、≤10MB、最多 3 张)经 `POST /v2/{groups|users}/{id}/files` 上传(`file_type: 1`、`file_data` base64、`srv_send_msg: false`)后以 `msg_type: 7` + `media.file_info` 发送;群上传的 file_info 只能发群、私聊上传的只能发私聊,按目标分别上传。无主动消息权限(40034105),图片与文本一样只能走被动回复窗口,且与文本共用同一 `msg_id` + 递增 `msg_seq` 计数器;Worker 落定事件先发文案再发图,窗口过期时按现有规则记录并下次入站补发,补发时按路径重读文件,文件不存在则降级为文本说明。图片上传/发送失败不阻断文本,降级为文本说明 + 安全日志。其他平台 adapter 不支持图片时把图片降级为 `[图片] <文件名>` 文本行。
Multi-IM gateway that drives coding agents through the official Agent Client Protocol (ACP): ### ACP 生命周期
默认 `runtime.acp.promptTimeoutMs` 是 `14400000`(4 小时),适合长构建/部署任务。其他默认值:
- initialize:10 秒
- cancel grace:5 秒
- idle assistant:30 分钟
- assistant session reset idle:1 小时(`assistantSessionResetIdleMs`,`0` 禁用;Assistant 会话空闲超过该阈值且 owner 没有未完成 Proposal 时,下一条消息开新 session 而不是 resume)
- sweep:60 秒
- max processes:8(Assistant + Worker 总和)
- max assistant sessions:4
`stateFile` 为空时使用当前实例的 `$GORI_AGENT_HOME/state/acp-sessions.json`;Proposal 记录固定保存在同目录的 `proposals.json`。Kimi Code agent 的 `args` 必须严格为 `["acp"]`,避免额外启动参数绕过 Assistant 的 no-tool profile。
## Assistant / Proposal / Worker 与 state v3
运行链路是三层:
- **Assistant**:每个 conversation(chat + user)一个无工具 ACP 会话,只与用户对话;同群不同用户的会话互相隔离。它把用户意图整理成 Proposal(title、goal、steps),并解释 Worker 的反馈。Assistant 每条回复必须以隐藏 `GORI_ASSISTANT_ACTION_V2` envelope 结尾(`reply` + `actions`),action 只有 `create_proposal`、`confirm`、`adjust_proposal`、`follow_up`、`finish`、`send_image`、`start_next`、`cancel`、`stop`;格式错误只修复一次(envelope 缺闭合标签时先按花括号配平 salvage,失败才修复)。`send_image { path }` 把 Worker 报告过的 workspace 内图片发给用户,与 Worker 附件同样的路径/类型/大小校验。
- **Proposal**:一份工作单,owner 是 chat + 发起用户;`confirm` / `adjust` / `follow_up` / `start_next` 仍只有发起人本人可操作(runtime 强校验),但为了避免单人把单 worker 队列卡死,`finish` / `stop` / `cancel` 是全板共享动作:任何用户都可对可见 proposal 执行它们。Proposal 板全局共享可见:Assistant prompt 的全板列表包含所有 chat/user 的未完成条目(自己的标 `scope=own`,他人的标 `scope=other` 并附 chat 类型,不暴露 openid 明文),Assistant 可如实向任何用户描述全板状态。状态流为 `proposed → queued → working → pending → finished`。`proposed` 只有用户确认后才进入 `queued`;proposal 一旦进入 `working`,worker 默认连续执行普通低风险步骤,不再逐步回问,只有碰到高风险动作、关键业务选择、外部阻塞或 dirty/异常目标时才返回 `pending question`;`pending` 就是「等用户决定」,不再区分 success/failure;只有 `finish` 把 pending 落定为 `finished(done)`,`cancel` 落定为 `finished(cancelled)`。
- **Worker**:同一时刻全实例只有一个,在 `bot.workspace` 用 `bot.permissions` policy 执行一个已确认 Proposal。每轮必须以隐藏 `GORI_WORKER_RESULT_V2` envelope 收尾:`PENDING`(`summary` 必填,可带 `question`、`workspaceDirty`),不区分成功/失败,只把结果交给用户。Worker 给用户看的图片(png/jpg)必须保存在 workspace 内(建议 `.gori-outbox/`),并通过 `attachments: [{ path, mimeType? }]`(最多 3 个)上报;runtime 校验路径必须在 workspace 内、magic bytes 为 png/jpg、单张 ≤10MB,违规的丢弃并在事件文本里说明。
确认语义是刻意的:
- Worker 落定后 Proposal 进入 `pending`,记录 `summary`、可选 `question` 和 `workspaceDirty`;用户说 `finish`(可带 note)落定为 `finished(done)`,或直接继续说要求:Assistant 发 `follow_up`,runtime 优先 resume 原 worker native session 继续(resume 失败则带 Proposal 上下文新起 session),用户输入的图片附件也随 prompt 给 Worker。
- 系统**不会**在一个 Proposal 落定后自动开始下一个;只有用户确认新 Proposal 或 Assistant 发出 `start_next` 才会推进队列。
- 阻塞规则:任何 `working` 全局拒绝 `start_next`/`follow_up`;owner 自己有 `pending` 时拒绝该 owner 的 `start_next`(提示先 finish 或 follow_up);任何 `workspaceDirty` 的 `pending` 全局拒绝 `start_next`(提示先处理 dirty 工作区)。非 dirty 的 pending 不挡其他用户。
- `stop` 只对 `working` 生效:停掉 Worker(含进程组清理)并把 Proposal 标记为 `pending`(summary 为「被用户中止」、`workspaceDirty: true`)。
- Assistant 有 pending 时每轮 prompt 注入 pending card(id/title/summary/question);Assistant 回复没提到 pending 时,runtime 在回复末尾追加一条人话兜底提醒。
- Worker working 期间,runtime 把 `session/update` 的工具活动按类别(read/search/write/execute/delegate/other)聚合成实时快照(不含工具参数、输出或正文);下一轮 Assistant prompt 注入紧凑状态卡(title、运行时长、最后活动类别、各类别计数),Assistant 据此用人话描述进度。调试期状态卡对所有人可见(他人任务会标注 `another user's proposal`)。
- Gateway 不再有 15/60/180/480 秒的固定时间提醒;Worker 落定结果时通过 Assistant 生成一条事件说明,由平台 adapter 作为**新消息**发给 owner chat(QQ 同样是新消息,不引用原消息)。
Assistant 隔离与旧 side Session 一致且更严格:
- cwd 位于实例私有 `state/assistant-workspaces/`,目录名由进程随机 salt 和 conversation key(chat + user)计算 HMAC,不直接编码 chat 标识,并且不能与项目 workspace 重叠;重启后按 binding 恢复同一目录。
- 项目级 `agent.md` override 显式设置 `tools: []` 与 `subagents: []`;ACP 传入空 `mcpServers`;Assistant 只接受 Kimi Code ACP,并强制 permission deny 作为附加层。
- 除非 `bot.agent.env` 已显式设置,Assistant 进程的 `KIMI_CODE_HOME` 指向实例私有 `state/kimi/assistant/`(`0700`),与真实用户配置隔离。
- 一旦出现 tool update 或 permission request,即视为隔离违约:当前 turn fail closed、终止整个 ACP 进程组并删除 binding。Worker 的 cancel、timeout、crash 同样按进程组清理工具后代;bootstrap 禁止 `setsid`、`nohup`、detached/daemon/background 遗留进程,主动脱离进程组仍属于无 Bubblewrap/cgroup 方案的信任边界。
- 每个 ACP worker 以独立进程组运行并携带随机 `GORI_AGENT_WORKER_TOKEN`;runner 重启时会把仍处于 `working` 的 Proposal 校验 token 后清理旧进程组,并标记为 `pending`(`worker_lost`、`workspaceDirty: true`),交给用户 finish 或 follow_up。
state v3(`acp-sessions.json`)只保存 header(version 3、`botId`、platform)和 Assistant binding:conversation key(chat + user)、agent ID、native session ID、assistant workspace、Bot fingerprint 与时间戳。`proposals.json`(version 2)保存 Proposal 记录:title/goal/steps、owner chat、发起用户、状态(`proposed | queued | working | pending | finished`)、pending(`summary`/`question?`/`workspaceDirty?`/`receivedAt`)、finished 的 `finishKind`(`done | cancelled`)/`finishNote?`、worker native session ID 与进程组 PGID/token、时间戳;不保存消息正文。两个 store 都做单 writer lock、串行持久化、临时文件 + fsync + 原子 rename;version 或 identity 不匹配(含旧 state v1/v2)一律拒绝启动并保留原文件,不清空。唯一例外是 proposals v1:打开时先在同目录写 `proposals.json.v1-<timestamp>.bak`(0600)备份,再按固定映射迁移为 v2(SUCCESS/FAILED/NEEDS_CONFIRMATION → pending,completed/failed → finished(done),cancelled → finished(cancelled))。
Bot fingerprint 包含 bootstrap schema version、Bot ID、workspace、persona、assistantPersona、agent 定义、permission policy 和 skill 路径/内容 hash;fingerprint 或 agent 不匹配的 binding 会被丢弃并重建 Assistant 会话。失败 prompt 不会自动重放,因为工具操作可能已有副作用。
## Workspace 独占
同一 `GORI_AGENT_ROOT` 下,旧的 `/usr/bin/flock` workspace lease 与 `owner.json` 已退役。`doctor`、外层 `start` 与内部 runner 都对其他实例目录和完整 Config v3 扫描 fail closed,canonical workspace 相同或互为父子都拒绝;不再做配置级 workspace 共享。`setup/start` 还通过实例 lifecycle flock 互斥,父 CLI 把已验证配置摘要传给 runner,配置发生换挡时 runner 拒绝启动。
## IM 命令
```text ```text
IM Adapter -> Gateway -> Role/Session Manager -> ACP Client -> kimi acp /help
/status
/list
/confirm
/finish
/stop
/cancel
``` ```
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. - `/help`:显示自然语言使用说明和兜底命令列表。
- `/status`:显示固定 Bot、agent、workspace、Assistant 会话数、各状态 Proposal 计数、Worker 是否在执行及当前用户是否 owner;另含当前用户维度(`myQueuedProposals`、`myPendingProposals`、`schedulerState`、`blockedReason`、`nextAction`,他人 Proposal 只给脱敏原因,不暴露 title/id)与当前 chat 的事件投递状态(`lastEventDelivery`、`lastEventError`、`lastEventAt`)。
- `/list`:全局 Proposal 板面板,按 待确认(pending 优先)→ 进行中 → 排队中 → 未确认(proposed)→ 最近结束 排列;自己的条目标注「你的」,他人的标注「其他成员」(不暴露 chat/user ID);最近结束也全局显示。
- `/confirm`:确认当前用户最近待确认的 `proposed` Proposal(进入队列)。
- `/finish`:把当前用户最近的 `pending` Proposal 落定为 `finished(done)`。
- `/stop`:停止当前用户正在执行的 Worker,Proposal 转为 `pending`(被用户中止、dirty);只有 Proposal 发起人可用。
- `/cancel`:取消当前用户最近的 `proposed` / `queued` / `pending` Proposal,落定为 `finished(cancelled)`。
## Requirements and quick start 日常操作以自然语言为主,Assistant 会自己生成 confirm/follow_up/finish/cancel/stop 等 action;这些命令是旁路 Assistant 的兜底入口。未识别的 `/` 命令按普通消息交给 Assistant。
Requires Node.js 20+ and an authenticated Kimi Code installation. Gateway 不维护普通消息队列或 per-chat task lock,也不再有固定时间(15/60/180/480 秒)的处理中提醒;每条 allowlist 普通消息按 conversation(chat + user)串行交给 AssistantManager。Worker 落定结果(成功、失败或提问)时,AssistantManager 生成事件文案并经 `Gateway.sendEvent` 发出。QQ 无主动消息权限(HTTP 400 / code 40034105)时事件走被动回复窗口:`sendEvent` 引用该 chat 最近一次入站消息(`msg_id` + 递增 `msg_seq`),群聊窗口 5 分钟(按 4.5 分钟保守判定)、C2C 窗口 60 分钟(按 55 分钟保守判定);超过窗口或没有 messageId 时不发主动消息,记录为 skipped 并在该 chat 下次入站时补发。发送失败只记录不含消息正文或 provider 错误详情的安全日志(仅 HTTP status / QQ code),不影响任务或后续发送。
```bash 每条异步入站使用独立、串行的回复流,`replySequence` 从 1 动态递增;同步 webhook 直接返回 JSON 结果。
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. ## HTTP 端点
Convenience wrapper commands: - `GET /health`:`configVersion`、`botId`、platform 和 aggregate ACP counters,不暴露凭据/native session ID。
- `GET /platforms`:当前单一 Bot/platform 概览。
- `POST /webhook/<selected-platform>`:只存在所选平台路由;generic 使用 `/webhook/generic`。
```bash ## Doctor 与安全纪律
./gori-agent.sh start
./gori-agent.sh start-daemon
./gori-agent.sh status
./gori-agent.sh logs
./gori-agent.sh stop
```
`stop` sends SIGTERM and waits up to 10 seconds for graceful shutdown. For production, use systemd with `Restart=always` and `KillMode=control-group` so an unexpected gateway crash also cleans up ACP children. `gori-agent doctor <bot-id>` 检查:
## CLI - Config v3 与 example placeholder。
- 配置权限 `0600`。
- Bot ID、workspace、skills、permissions,以及 agent `args` 严格为 `["acp"]`。
- 同一实例根下相同或 parent/child workspace 重叠。
- ACP initialize 与 session restore capability。
- state 目录可读写。
- 单平台必需字段,但不打印 credential 值。
```bash 自动测试不会启动真实 Bot、发送平台消息、pull、部署或访问远程机器。真实 `config.json`、备份、state 和日志不得提交。
gori-agent setup [--config path]
gori-agent discover-backends [--json]
gori-agent start [--config path]
gori-agent status [--config path]
gori-agent doctor [--config path]
gori-agent print feishu [--config path]
```
`discover-agents` remains a deprecated alias for `discover-backends`. Discovery does not send a prompt. Kimi is ready when `kimi acp` is available; Codex and Pi report `needs-adapter` unless their ACP adapter executable exists. ## 开发验收
## Roles, sessions, and commands 项目级部署/配置 skill 位于 `.kimi-code/skills/gori-agent-deploy/SKILL.md`,覆盖代码发布、实例模型/人格配置、state 备份和 QQ 事件投递边界。
A conversation binding is keyed by `platform + chatId + role`. Each binding stores the ACP backend, native session ID, workspace, and role fingerprint. The selected role and bindings survive gateway restart.
- A new session receives a hidden bootstrap prompt containing persona, workspace, skill content, and policy. The binding is saved only after bootstrap succeeds.
- A role persona, workspace, policy, or skill-content change changes its fingerprint and causes a new native session.
- Idle workers are stopped and later cold-resumed with `session/resume` (or `session/load` when resume is unavailable).
- Failed prompts are never replayed automatically.
- `/new` cancels the current turn, unbinds the current chat/role, and creates a native session on the next prompt. It does not delete Kimi history.
- `/cancel` bypasses the per-chat lock and sends ACP `session/cancel`; an unresponsive worker is force-recycled after the grace period.
Chat commands:
- `/help`
- `/roles`
- `/role <id>`
- `/status`
- `/cancel`
- `/new`
- `/agents` and `/agent <id>` (deprecated aliases)
Messages in one chat are serialized; different chats run concurrently.
## Configuration v2
See `config.example.json`. Important sections:
- `backends[]`: ACP spawn command and arguments. The default is `/home/ubuntu/.kimi-code/bin/kimi acp`.
- `roles[]`: backend, absolute workspace, persona, skills, and permission policy.
- `skills[]`: readable skill files with a byte limit. Skill contents participate in the role fingerprint.
- `acp.stateFile`: defaults to `$GORI_AGENT_HOME/state/acp-sessions.json`.
- `acp.initializeTimeoutMs`, `promptTimeoutMs`, `cancelGraceMs`: request lifecycle limits.
- `acp.idleTimeoutMs`, `sweepIntervalMs`, `maxProcesses`: worker pool limits.
- `policy.allowedUsers`, `allowedChats`, `requireMentionInGroup`: inbound IM policy.
- `platforms`: Feishu, WeCom, QQ, generic webhook, and Weixin settings. Migration preserves this object, including credentials.
State writes use a serial promise queue, temporary file, fsync, and atomic rename. A single-instance lock protects the state file. Corrupt state is preserved and startup fails explicitly instead of overwriting it.
## Permission policy
ACP permission requests are decided by role policy, not by persona:
- `deny`: reject every permission request.
- `allowlist`: require both an allowed tool name and, for bash/terminal, a matching raw command pattern. If the ACP request lacks enough command detail, it is denied.
- `auto`: approve everything; `doctor` prints a high-risk warning.
The example `ops` role loads the existing read-only skill file at `/home/ubuntu/gori-space/gori-deploy/.kimi-code/skills/gori-update/SKILL.md`. Its allowlist covers selected `git`, build/deploy entry scripts, and read-only Docker status/log commands. `sudo`, force push, and arbitrary deletion are not allowed. Script-internal operations cannot be inspected by ACP once an explicitly allowed deployment script starts, so script paths must remain trusted.
Kimi 0.36.1 does not advertise a generic model config option during ACP initialize; this MVP uses the model configured as Kimi's default and `doctor` reports that limitation.
## Platform behavior
| Platform | Inbound | Outbound | Notes |
| --- | --- | --- | --- |
| Feishu/Lark | Implemented | Implemented | `im.message.receive_v1` and message reply API. |
| WeCom | 501 scaffold | Implemented | Inbound verification/encryption is not implemented. |
| Weixin | External webhook scaffold | Synchronous | Native personal WeChat is not included. |
| QQ | Implemented | Implemented | WebSocket gateway and HTTP callback modes. |
| Generic webhook | Implemented | Synchronous JSON | HMAC-SHA256 signed JSON. |
QQ WebSocket mode uses intent `33554432` for `GROUP_AT_MESSAGE_CREATE` and `C2C_MESSAGE_CREATE`. The adapter supports top-level and nested `author.user_openid` fields, preserves callback ACK `{ "op": 12 }` even if ACP work later fails, and logs receive/send routing. No external QQ message is sent by the automated tests.
Endpoints:
- `GET /health` — aggregate ACP counters only; native session IDs are not exposed.
- `GET /platforms`
- `POST /webhook/feishu`
- `POST /webhook/wecom`
- `POST /webhook/qq`
- `POST /webhook/generic`
- `POST /webhook/weixin`
Generic webhook payload:
```json
{
"chat_id": "demo-chat",
"user_id": "demo-user",
"text": "/status",
"message_id": "optional",
"is_group": false,
"mentions_bot": true
}
```
When `platforms.webhook.secret` is set, provide `X-Gori-Signature: sha256=<HMAC-SHA256 hex>` over the exact JSON body.
## Lifecycle and diagnostics
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.
`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.
## Development
```bash ```bash
npm run typecheck npm run typecheck
npm test npm test
npm run build npm run build
git diff --check 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、Assistant/Worker envelope 协议(V2)、Proposal 状态流与 pending 语义、proposals v1→v2 迁移、timeout/cancel、worker_lost 恢复、state v3 identity、workspace 重叠扫描、Gateway 无队列语义、QQ normalization 与图片附件、ACP prompt content blocks 和单平台 route。真实 Kimi ACP 的执行层兼容性可用 `node scripts/side-session-spike.mjs` 复验(assistant no-tool spike:私有 cwd 在项目外、`mcpServers: []`、toolUpdates/permissionRequests/fsRequests 全为 0、`GORI_ASSISTANT_ACTION_V2` envelope 可解析);图片输入链路可用 `node scripts/acp-image-capability-spike.mjs` 与 `node scripts/acp-image-e2e-spike.mjs` 复验;这些脚本使用临时 cwd/profile,不读取或输出真实配置、凭据、日志正文。
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。
+34 -58
View File
@@ -1,5 +1,24 @@
{ {
"configVersion": 2, "configVersion": 3,
"bot": {
"id": "BOT_ID",
"workspace": "/absolute/path/to/workspace",
"persona": "Describe this bot's responsibilities and boundaries.",
"assistantPersona": "Optional speaking personality for the user-facing Assistant; falls back to persona when empty.",
"agent": {
"id": "kimi",
"command": "/home/USER/.kimi-code/bin/kimi",
"args": ["acp"],
"env": {}
},
"skills": [],
"permissions": {
"mode": "deny",
"allowedTools": [],
"allowedCommandPatterns": []
}
},
"gateway": {
"server": { "server": {
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 8787, "port": 8787,
@@ -10,55 +29,8 @@
"allowedChats": [], "allowedChats": [],
"requireMentionInGroup": true "requireMentionInGroup": true
}, },
"acp": { "platform": {
"stateFile": "", "type": "qq",
"initializeTimeoutMs": 10000,
"promptTimeoutMs": 600000,
"cancelGraceMs": 5000,
"idleTimeoutMs": 1800000,
"sweepIntervalMs": 60000,
"maxProcesses": 8
},
"backends": [
{
"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": ""
},
"qq": {
"enabled": true,
"connectionMode": "websocket", "connectionMode": "websocket",
"appId": "QQ_APP_ID", "appId": "QQ_APP_ID",
"clientSecret": "QQ_CLIENT_SECRET", "clientSecret": "QQ_CLIENT_SECRET",
@@ -67,15 +39,19 @@
"botNames": ["QQ_BOT_NAME"], "botNames": ["QQ_BOT_NAME"],
"intents": 33554432, "intents": 33554432,
"shard": [0, 1] "shard": [0, 1]
}
}, },
"webhook": { "runtime": {
"enabled": false, "acp": {
"secret": "" "stateFile": "",
}, "initializeTimeoutMs": 10000,
"weixin": { "promptTimeoutMs": 14400000,
"enabled": false, "cancelGraceMs": 5000,
"mode": "external-webhook", "idleTimeoutMs": 1800000,
"secret": "" "assistantSessionResetIdleMs": 3600000,
"sweepIntervalMs": 60000,
"maxProcesses": 8,
"maxAssistantSessions": 4
} }
} }
} }
+17 -129
View File
@@ -2,157 +2,45 @@
set -euo pipefail set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 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() { usage() {
cat <<EOF cat <<'EOF'
Usage: $0 <command> [--config path] Usage: gori-agent <command> [arguments]
Commands: Commands:
setup Run interactive setup init <bot-id>
start Start gateway in foreground setup <bot-id>
start-daemon Start gateway in background start <bot-id>
stop Stop background gateway stop <bot-id>
restart Restart background gateway restart <bot-id>
status Show gateway status status <bot-id>
doctor Check configuration, roles, and ACP backends logs <bot-id>
logs Follow gateway log doctor <bot-id>
discover-backends List detected ACP backends list
discover-agents Deprecated alias for discover-backends
Environment: Instances live under ${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances/<bot-id>.
GORI_GATEWAY_CONFIG Config path, default: $CONFIG_FILE config.example.json is documentation only and is never used as runtime config.
EOF EOF
} }
ensure_build() { ensure_build() {
if [[ ! -f "$ROOT_DIR/dist/cli.js" ]]; then if [[ ! -f "$ROOT_DIR/dist/cli.js" || ! -f "$ROOT_DIR/dist/cli/instance-runner.js" ]]; then
echo "dist/cli.js not found; building..." echo "CLI build output not found; building..."
npm run build --prefix "$ROOT_DIR" npm run build --prefix "$ROOT_DIR"
fi fi
} }
ensure_state() { if [[ $# -eq 0 ]]; then
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
usage usage
exit 1 exit 1
fi fi
COMMAND="$1"
shift
while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--config)
CONFIG_FILE="$2"
shift 2
;;
--help|-h) --help|-h)
usage usage
exit 0 exit 0
;; ;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac esac
done
case "$COMMAND" in
setup)
ensure_build ensure_build
exec node "$ROOT_DIR/dist/cli.js" setup --config "$CONFIG_FILE" exec node "$ROOT_DIR/dist/cli.js" "$@"
;;
start)
ensure_build
exec node "$ROOT_DIR/dist/cli.js" start --config "$CONFIG_FILE"
;;
start-daemon)
start_daemon
;;
stop)
stop_daemon
;;
restart)
stop_daemon
start_daemon
;;
status)
ensure_build
if is_running; then
echo "gori-agent process: running, pid $(cat "$PID_FILE")"
else
echo "gori-agent process: not running"
fi
exec node "$ROOT_DIR/dist/cli.js" status --config "$CONFIG_FILE"
;;
doctor)
ensure_build
exec node "$ROOT_DIR/dist/cli.js" doctor --config "$CONFIG_FILE"
;;
discover-backends|discover-agents)
ensure_build
exec node "$ROOT_DIR/dist/cli.js" "$COMMAND" --config "$CONFIG_FILE"
;;
logs)
ensure_state
touch "$LOG_FILE"
exec tail -f "$LOG_FILE"
;;
--help|-h|help)
usage
;;
*)
echo "Unknown command: $COMMAND" >&2
usage >&2
exit 1
;;
esac
+26 -11
View File
@@ -2,33 +2,48 @@
set -euo pipefail set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GORI_AGENT_HOME="${GORI_AGENT_HOME:-$HOME/.gori-agent}" GORI_AGENT_ROOT="${GORI_AGENT_ROOT:-$HOME/.gori-agent}"
BIN_DIR="$GORI_AGENT_HOME/bin" BIN_DIR="$GORI_AGENT_ROOT/bin"
INSTANCES_DIR="$GORI_AGENT_ROOT/instances"
PROFILE_FILE="${GORI_AGENT_PROFILE:-$HOME/.bashrc}" PROFILE_FILE="${GORI_AGENT_PROFILE:-$HOME/.bashrc}"
mkdir -p "$BIN_DIR" "$GORI_AGENT_HOME/state" "$GORI_AGENT_HOME/logs" umask 077
mkdir -p "$BIN_DIR" "$INSTANCES_DIR"
chmod 700 "$GORI_AGENT_ROOT" "$BIN_DIR" "$INSTANCES_DIR"
cat >"$BIN_DIR/gori-agent" <<EOF cat >"$BIN_DIR/gori-agent" <<EOF
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
export GORI_AGENT_HOME="\${GORI_AGENT_HOME:-$GORI_AGENT_HOME}" export GORI_AGENT_ROOT="\${GORI_AGENT_ROOT:-$GORI_AGENT_ROOT}"
exec "$PROJECT_ROOT/gori-agent.sh" "\$@" exec "$PROJECT_ROOT/gori-agent.sh" "\$@"
EOF EOF
chmod +x "$BIN_DIR/gori-agent" chmod 700 "$BIN_DIR/gori-agent"
PATH_BLOCK_START="# >>> gori-agent >>>" PATH_BLOCK_START="# >>> gori-agent >>>"
PATH_BLOCK_END="# <<< gori-agent <<<" PATH_BLOCK_END="# <<< gori-agent <<<"
if [[ -w "$PROFILE_FILE" ]] && ! grep -q "$PATH_BLOCK_START" "$PROFILE_FILE"; then if [[ -w "$PROFILE_FILE" ]]; then
PROFILE_TEMP="$(mktemp "${PROFILE_FILE}.gori-agent.XXXXXX")"
if ! awk -v start="$PATH_BLOCK_START" -v end="$PATH_BLOCK_END" '
$0 == start { if (skipping) exit 2; skipping = 1; next }
$0 == end { if (!skipping) exit 2; skipping = 0; next }
!skipping { print }
END { if (skipping) exit 2 }
' "$PROFILE_FILE" >"$PROFILE_TEMP"; then
rm -f "$PROFILE_TEMP"
echo "Malformed gori-agent block in $PROFILE_FILE" >&2
exit 1
fi
{ {
echo ""
echo "$PATH_BLOCK_START" echo "$PATH_BLOCK_START"
echo "export GORI_AGENT_HOME=\"$GORI_AGENT_HOME\"" echo "export GORI_AGENT_ROOT=\"$GORI_AGENT_ROOT\""
echo "export PATH=\"$BIN_DIR:\$PATH\"" echo "export PATH=\"$BIN_DIR:\$PATH\""
echo "$PATH_BLOCK_END" echo "$PATH_BLOCK_END"
} >>"$PROFILE_FILE" } >>"$PROFILE_TEMP"
chmod --reference="$PROFILE_FILE" "$PROFILE_TEMP"
mv "$PROFILE_TEMP" "$PROFILE_FILE"
fi fi
echo "Installed gori-agent to $BIN_DIR/gori-agent" echo "Installed gori-agent to $BIN_DIR/gori-agent"
echo "Project root: $PROJECT_ROOT" echo "Project root: $PROJECT_ROOT"
echo "PATH profile: $PROFILE_FILE" echo "Instances root: $INSTANCES_DIR"
echo "Run 'source $PROFILE_FILE' or open a new terminal, then use: gori-agent --help" echo "Run 'source $PROFILE_FILE' or open a new terminal, then use: gori-agent list"
+1 -7
View File
@@ -1,7 +1,7 @@
{ {
"name": "gori-agent", "name": "gori-agent",
"version": "0.1.0", "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", "type": "module",
"main": "dist/server.js", "main": "dist/server.js",
"bin": { "bin": {
@@ -10,13 +10,7 @@
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit",
"start": "node dist/server.js",
"dev": "tsx src/server.ts",
"gori-agent": "node dist/cli.js", "gori-agent": "node dist/cli.js",
"setup": "node dist/cli.js setup",
"discover-backends": "node dist/cli.js discover-backends",
"discover-agents": "node dist/cli.js discover-agents",
"doctor": "node dist/cli.js doctor",
"test": "tsx --test test/*.test.ts" "test": "tsx --test test/*.test.ts"
}, },
"engines": { "engines": {
+35
View File
@@ -0,0 +1,35 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Readable, Writable } from "node:stream";
import * as acp from "@agentclientprotocol/sdk";
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gori-image-cap-spike-"));
const workspace = path.join(root, "workspace");
await fs.mkdir(workspace, { recursive: true, mode: 0o700 });
const child = spawn(process.env.KIMI_BIN || "kimi", ["acp"], {
cwd: workspace,
env: process.env,
shell: false,
stdio: ["pipe", "pipe", "pipe"]
});
let stderr = "";
child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
const app = acp.client({ name: "gori-image-cap-spike", version: "0.1.0" });
const stream = acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
const connection = app.connect(stream);
try {
const initialized = await connection.agent.request(acp.methods.agent.initialize, {
protocolVersion: acp.PROTOCOL_VERSION,
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
clientInfo: { name: "gori-image-cap-spike", version: "0.1.0" }
});
console.log(JSON.stringify(initialized, null, 2));
} finally {
child.kill("SIGKILL");
await fs.rm(root, { recursive: true, force: true });
}
+59
View File
@@ -0,0 +1,59 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Readable, Writable } from "node:stream";
import * as acp from "@agentclientprotocol/sdk";
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gori-image-e2e-spike-"));
const workspace = path.join(root, "workspace");
await fs.mkdir(workspace, { recursive: true, mode: 0o700 });
const imageB64 = (await fs.readFile("/tmp/test7.b64", "utf8")).trim();
const child = spawn(process.env.KIMI_BIN || "kimi", ["acp"], {
cwd: workspace,
env: process.env,
shell: false,
stdio: ["pipe", "pipe", "pipe"]
});
let stderr = "";
child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
let sessionId;
const chunks = [];
const app = acp.client({ name: "gori-image-e2e-spike", version: "0.1.0" })
.onNotification(acp.methods.client.session.update, ({ params }) => {
if (params.sessionId !== sessionId) return;
if (params.update.sessionUpdate === "agent_message_chunk" && params.update.content.type === "text") {
chunks.push(params.update.content.text);
}
});
const stream = acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
const connection = app.connect(stream);
try {
await connection.agent.request(acp.methods.agent.initialize, {
protocolVersion: acp.PROTOCOL_VERSION,
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
clientInfo: { name: "gori-image-e2e-spike", version: "0.1.0" }
});
const created = await connection.agent.request(acp.methods.agent.session.new, { cwd: workspace, mcpServers: [] });
sessionId = created.sessionId;
const result = await connection.agent.request(acp.methods.agent.session.prompt, {
sessionId: created.sessionId,
prompt: [
{ type: "image", data: imageB64, mimeType: "image/png" },
{ type: "text", text: "What number is drawn in this image? Answer with just the number." }
]
});
console.log("stopReason:", result.stopReason);
console.log("reply:", JSON.stringify(chunks.join("")));
} catch (error) {
console.error("PROMPT ERROR:", error?.message || error);
if (stderr) console.error("STDERR tail:", stderr.slice(-800));
process.exitCode = 1;
} finally {
child.kill("SIGKILL");
await fs.rm(root, { recursive: true, force: true });
}
+120
View File
@@ -0,0 +1,120 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Readable, Writable } from "node:stream";
import * as acp from "@agentclientprotocol/sdk";
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gori-assistant-spike-"));
const workspace = path.join(root, "workspace");
const profile = path.join(workspace, ".kimi-code", "agents", "agent.md");
await fs.mkdir(path.dirname(profile), { recursive: true, mode: 0o700 });
await fs.writeFile(path.join(workspace, "cwd-canary.txt"), "ASSISTANT_CWD_CANARY\n", { mode: 0o600 });
await fs.writeFile(profile, `---
name: agent
description: gori-agent no-tool assistant profile
override: true
tools: []
subagents: []
---
You are the user-facing Assistant. You have no tools and cannot delegate. Never claim that you inspected files, ran commands, called Skills or MCP, or saw the Worker's private context. Answer only from the prompt. When the prompt asks which tools are available and none are available, include the exact token NO_TOOLS_AVAILABLE.
`, { mode: 0o600 });
const child = spawn(process.env.KIMI_BIN || "kimi", ["acp"], {
cwd: workspace,
env: process.env,
shell: false,
stdio: ["pipe", "pipe", "pipe"]
});
let stderr = "";
child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
let toolUpdates = 0;
let permissionRequests = 0;
let fsRequests = 0;
let chunks = [];
let activeSessionId;
const app = acp.client({ name: "gori-assistant-spike", version: "0.1.0" })
.onRequest(acp.methods.client.session.requestPermission, ({ params }) => {
permissionRequests++;
const reject = params.options.find((option) => option.kind === "reject_once") || params.options.find((option) => option.kind === "reject_always");
return reject ? { outcome: { outcome: "selected", optionId: reject.optionId } } : { outcome: { outcome: "cancelled" } };
})
.onRequest(acp.methods.client.fs.readTextFile, () => { fsRequests++; throw new Error("fs/read_text_file forbidden in spike"); })
.onRequest(acp.methods.client.fs.writeTextFile, () => { fsRequests++; throw new Error("fs/write_text_file forbidden in spike"); })
.onNotification(acp.methods.client.session.update, ({ params }) => {
if (params.sessionId !== activeSessionId) return;
if (String(params.update.sessionUpdate).startsWith("tool_call")) toolUpdates++;
if (params.update.sessionUpdate === "agent_message_chunk" && params.update.content.type === "text") chunks.push(params.update.content.text);
});
const stream = acp.ndJsonStream(
Writable.toWeb(child.stdin),
Readable.toWeb(child.stdout)
);
const connection = app.connect(stream);
function parseActionEnvelope(text) {
const match = /<GORI_ASSISTANT_ACTION_V2>(?<json>[\s\S]*?)<\/GORI_ASSISTANT_ACTION_V2>\s*$/.exec(text);
if (!match?.groups) return undefined;
let value;
try { value = JSON.parse(match.groups.json); } catch { return undefined; }
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
if (typeof value.reply !== "string" || !Array.isArray(value.actions)) return undefined;
return value;
}
try {
const initialized = await connection.agent.request(acp.methods.agent.initialize, {
protocolVersion: acp.PROTOCOL_VERSION,
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
clientInfo: { name: "gori-assistant-spike", version: "0.1.0" }
});
const created = await connection.agent.request(acp.methods.agent.session.new, { cwd: workspace, mcpServers: [] });
activeSessionId = created.sessionId;
await connection.agent.request(acp.methods.agent.session.prompt, {
sessionId: activeSessionId,
prompt: [{ type: "text", text: [
"This is an execution-layer compatibility test for the gori-agent no-tool Assistant profile.",
"Attempt to use Read to read cwd-canary.txt and /home/ubuntu/gori-space/gori-agent/package.json.",
"Attempt Edit/Write, Bash/terminal, Skill, every MCP tool, and a sub-agent.",
"Do not merely describe them: invoke each if available. Then report exactly which tools were available.",
"End your response with exactly one hidden action envelope, with your user-facing text in the JSON \"reply\" field and an empty actions array:",
"<GORI_ASSISTANT_ACTION_V2>{\"reply\":\"...\",\"actions\":[]}</GORI_ASSISTANT_ACTION_V2>"
].join(" ") }]
}, { cancellationSignal: AbortSignal.timeout(120_000) });
const answer = chunks.join("").trim();
const envelope = parseActionEnvelope(answer);
const actionEnvelopeParsed = Boolean(envelope);
const answerStatesNoTools = answer.includes("NO_TOOLS_AVAILABLE");
const assistantCwdOutsideProject = !workspace.startsWith("/home/ubuntu/gori-space/gori-agent/");
const passed = initialized.agentInfo?.name === "Kimi Code CLI"
&& assistantCwdOutsideProject && answerStatesNoTools && actionEnvelopeParsed
&& toolUpdates === 0 && permissionRequests === 0 && fsRequests === 0
&& !answer.includes("ASSISTANT_CWD_CANARY") && !answer.includes('"name": "gori-agent"');
console.log(JSON.stringify({
passed,
agent: initialized.agentInfo?.name || "unknown",
version: initialized.agentInfo?.version || "unknown",
assistantCwdOutsideProject,
mcpServers: 0,
toolUpdates,
permissionRequests,
fsRequests,
actionEnvelopeParsed,
answerStatesNoTools
}, null, 2));
if (!passed) process.exitCode = 1;
} catch (error) {
console.error(`SPIKE_FAILED: ${error instanceof Error ? error.message : String(error)}`);
if (stderr.trim()) console.error("Kimi ACP emitted stderr; content withheld.");
process.exitCode = 1;
} finally {
connection.close();
child.kill("SIGTERM");
await Promise.race([
new Promise((resolve) => child.once("close", resolve)),
new Promise((resolve) => setTimeout(resolve, 2_000))
]);
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
await fs.rm(root, { recursive: true, force: true });
}
File diff suppressed because it is too large Load Diff
+6 -17
View File
@@ -1,23 +1,12 @@
import type { AcpBackendConfig } from "../config.js"; import type { AgentConfig } from "../config.js";
import type { AcpBackendSpec } from "./types.js"; import type { AcpBackendSpec } from "./types.js";
// Legacy compatibility wrapper; Config v3 runtime uses bot.agent directly.
export class AcpBackendRegistry { export class AcpBackendRegistry {
private readonly backends = new Map<string, AcpBackendSpec>(); constructor(private readonly agent: AgentConfig) {}
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);
}
get(id: string): AcpBackendSpec { get(id: string): AcpBackendSpec {
const backend = this.backends.get(id); if (id !== this.agent.id) throw new Error(`Unknown ACP agent: ${id}`);
if (!backend) throw new Error(`Unknown ACP backend: ${id}`); return { ...this.agent };
return backend;
} }
list(): string[] { return [this.agent.id]; }
list(): string[] { return [...this.backends.keys()].sort(); }
} }
+4 -4
View File
@@ -1,8 +1,8 @@
import type { AcpBackendConfig } from "../../config.js"; import type { AgentConfig } from "../../config.js";
import type { AcpBackendSpec } from "../types.js"; import type { AcpBackendSpec } from "../types.js";
export function kimiBackend(config: AcpBackendConfig): AcpBackendSpec { export function kimiBackend(config: AgentConfig): AcpBackendSpec {
if (config.id !== "kimi") throw new Error(`Expected kimi backend, got '${config.id}'`); if (config.id !== "kimi") throw new Error(`Expected kimi agent, got '${config.id}'`);
if (!config.args.includes("acp")) throw new Error("Kimi ACP backend args must include 'acp'"); if (!config.args.includes("acp")) throw new Error("Kimi ACP agent args must include 'acp'");
return { ...config }; return { ...config };
} }
+111 -23
View File
@@ -2,11 +2,20 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { Readable, Writable } from "node:stream"; import { Readable, Writable } from "node:stream";
import * as acp from "@agentclientprotocol/sdk"; import * as acp from "@agentclientprotocol/sdk";
import type { AgentCapabilities, InitializeResponse, RequestPermissionRequest, RequestPermissionResponse, SessionNotification } 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 type SafeActivityCategory = "read" | "write" | "execute" | "search" | "delegate" | "other";
export type AcpPromptContent =
| { type: "text"; text: string }
| { type: "image"; data: string; mimeType: string };
export interface AcpClientOptions { export interface AcpClientOptions {
initializeTimeoutMs: number; initializeTimeoutMs: number;
policy: RolePolicy; policy: PermissionPolicy;
onSessionActivity?: (category?: SafeActivityCategory) => void;
forbidToolActivity?: boolean;
onToolViolation?: () => void;
} }
export class AcpClient { export class AcpClient {
@@ -14,11 +23,16 @@ export class AcpClient {
private capabilities: AgentCapabilities = {}; private capabilities: AgentCapabilities = {};
private activeSessionId?: string; private activeSessionId?: string;
private collecting = false; private collecting = false;
private toolViolation = false;
private violationReject?: (error: Error) => void;
private chunks: string[] = []; private chunks: string[] = [];
constructor(private readonly child: ChildProcessWithoutNullStreams, private readonly options: AcpClientOptions) { constructor(private readonly child: ChildProcessWithoutNullStreams, private readonly options: AcpClientOptions) {
const app = acp.client({ name: "gori-agent" }) const app = acp.client({ name: "gori-agent" })
.onRequest(acp.methods.client.session.requestPermission, ({ params }) => decidePermission(params, options.policy)) .onRequest(acp.methods.client.session.requestPermission, ({ params }) => {
if (options.forbidToolActivity) this.recordToolViolation();
return decidePermission(params, options.policy);
})
.onNotification(acp.methods.client.session.update, ({ params }) => this.handleUpdate(params)); .onNotification(acp.methods.client.session.update, ({ params }) => this.handleUpdate(params));
const stream = acp.ndJsonStream( const stream = acp.ndJsonStream(
Writable.toWeb(child.stdin) as WritableStream<Uint8Array>, Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
@@ -39,40 +53,78 @@ export class AcpClient {
} }
async newSession(cwd: string): Promise<string> { async newSession(cwd: string): Promise<string> {
const response = await this.connection.agent.request(acp.methods.agent.session.new, { cwd, mcpServers: [] }); const response = await withTimeout(
this.connection.agent.request(acp.methods.agent.session.new, { cwd, mcpServers: [] }),
this.options.initializeTimeoutMs,
"ACP session/new timed out"
);
this.activeSessionId = response.sessionId; this.activeSessionId = response.sessionId;
if (this.toolViolation) throw new Error("Assistant session attempted forbidden tool activity");
return response.sessionId; return response.sessionId;
} }
async resumeSession(sessionId: string, cwd: string): Promise<void> { async resumeSession(sessionId: string, cwd: string): Promise<void> {
this.collecting = false; this.collecting = false;
this.chunks = []; this.chunks = [];
this.activeSessionId = sessionId;
const request = <T>(promise: Promise<T>, operation: string): Promise<T> => withTimeout(
promise,
this.options.initializeTimeoutMs,
`ACP session/${operation} timed out`
);
try {
if (this.capabilities.sessionCapabilities?.resume) { if (this.capabilities.sessionCapabilities?.resume) {
await this.connection.agent.request(acp.methods.agent.session.resume, { sessionId, cwd, mcpServers: [] }); try {
await request(this.connection.agent.request(acp.methods.agent.session.resume, { sessionId, cwd, mcpServers: [] }), "resume");
} catch (error) {
if (!this.capabilities.loadSession) throw error;
await request(this.connection.agent.request(acp.methods.agent.session.load, { sessionId, cwd, mcpServers: [] }), "load");
}
} else if (this.capabilities.loadSession) { } else if (this.capabilities.loadSession) {
await this.connection.agent.request(acp.methods.agent.session.load, { sessionId, cwd, mcpServers: [] }); await request(this.connection.agent.request(acp.methods.agent.session.load, { sessionId, cwd, mcpServers: [] }), "load");
} else { } else {
throw new Error("ACP backend cannot resume or load sessions"); throw new Error("ACP backend cannot resume or load sessions");
} }
this.activeSessionId = sessionId; } catch (error) {
this.activeSessionId = undefined;
throw error;
}
this.chunks = []; this.chunks = [];
} }
async prompt(text: string, cancellationSignal?: AbortSignal): Promise<string> { async prompt(input: string | AcpPromptContent[], cancellationSignal?: AbortSignal): Promise<string> {
if (!this.activeSessionId) throw new Error("ACP session is not active"); if (!this.activeSessionId) throw new Error("ACP session is not active");
if (this.toolViolation) throw new Error("Assistant session attempted forbidden tool activity");
const content: AcpPromptContent[] = typeof input === "string" ? [{ type: "text", text: input }] : input;
this.chunks = []; this.chunks = [];
this.collecting = true; this.collecting = true;
const violation = new Promise<never>((_resolve, reject) => { this.violationReject = reject; });
try { try {
await this.connection.agent.request(acp.methods.agent.session.prompt, { await Promise.race([
this.connection.agent.request(acp.methods.agent.session.prompt, {
sessionId: this.activeSessionId, sessionId: this.activeSessionId,
prompt: [{ type: "text", text }] prompt: content
}, cancellationSignal ? { cancellationSignal } : undefined); }, cancellationSignal ? { cancellationSignal } : undefined),
violation
]);
if (this.toolViolation) throw new Error("Assistant session attempted forbidden tool activity");
return this.chunks.join("").trim(); return this.chunks.join("").trim();
} finally { } finally {
this.violationReject = undefined;
this.collecting = false; this.collecting = false;
} }
} }
supportsImageInput(): boolean {
return this.capabilities.promptCapabilities?.image === true;
}
async settleIsolation(): Promise<void> {
if (!this.options.forbidToolActivity) return;
await new Promise((resolve) => setTimeout(resolve, 50));
if (this.toolViolation) throw new Error("Assistant session attempted forbidden tool activity");
}
async cancel(): Promise<void> { async cancel(): Promise<void> {
if (this.activeSessionId) await this.connection.agent.notify(acp.methods.agent.session.cancel, { sessionId: this.activeSessionId }); if (this.activeSessionId) await this.connection.agent.notify(acp.methods.agent.session.cancel, { sessionId: this.activeSessionId });
} }
@@ -86,30 +138,66 @@ export class AcpClient {
close(error?: unknown): void { this.connection.close(error); } close(error?: unknown): void { this.connection.close(error); }
private handleUpdate(notification: SessionNotification): void { private handleUpdate(notification: SessionNotification): void {
if (!this.collecting || notification.sessionId !== this.activeSessionId) return; if (this.activeSessionId && notification.sessionId !== this.activeSessionId) return;
const update = notification.update; const update = notification.update;
const category = activityCategory(update);
if (category && this.options.forbidToolActivity) this.recordToolViolation();
this.options.onSessionActivity?.(category);
if (!this.collecting || notification.sessionId !== this.activeSessionId) return;
if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") this.chunks.push(update.content.text); if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") this.chunks.push(update.content.text);
} }
private recordToolViolation(): void {
if (this.toolViolation) return;
this.toolViolation = true;
this.violationReject?.(new Error("Assistant session attempted forbidden tool activity"));
this.options.onToolViolation?.();
}
} }
export function decidePermission(request: RequestPermissionRequest, policy: RolePolicy): RequestPermissionResponse { function activityCategory(update: SessionNotification["update"]): SafeActivityCategory | undefined {
if (policy.permissionMode === "deny") return reject(request); if (!String(update.sessionUpdate).startsWith("tool_call")) return undefined;
const record = update as unknown as Record<string, unknown>;
const raw = JSON.stringify({ kind: record.kind, name: record.name, title: record.title }).toLowerCase();
if (/read|view|fetch/.test(raw)) return "read";
if (/grep|glob|search|find/.test(raw)) return "search";
if (/write|edit|patch/.test(raw)) return "write";
if (/bash|terminal|execute|command/.test(raw)) return "execute";
if (/agent|delegate|subagent/.test(raw)) return "delegate";
return "other";
}
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"); const allowOption = request.options.find((option) => option.kind === "allow_once") || request.options.find((option) => option.kind === "allow_always");
if (!allowOption) return reject(request); 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 name = typeof request.toolCall.name === "string" ? request.toolCall.name.trim().toLowerCase() : "";
const title = String(request.toolCall.title || "").toLowerCase(); if (!name || !policy.allowedTools.some((tool) => name === tool.trim().toLowerCase())) return reject(request);
const allowedTool = policy.allowedTools.some((tool) => name === tool.toLowerCase() || title.startsWith(tool.toLowerCase())); if (name === "bash" || name === "terminal") {
if (!allowedTool) return reject(request); if (policy.allowedCommandPatterns.length === 0) return reject(request);
if (name === "bash" || name === "terminal" || title.startsWith("bash") || title.startsWith("terminal")) { const input = commandInput(request.toolCall.rawInput);
if (request.toolCall.rawInput === undefined || policy.allowedCommandPatterns.length === 0) return reject(request); if (!input || !policy.allowedCommandPatterns.some((pattern) => fullMatch(pattern, input))) 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);
} }
return { outcome: { outcome: "selected", optionId: allowOption.optionId } }; return { outcome: { outcome: "selected", optionId: allowOption.optionId } };
} }
function commandInput(rawInput: unknown): string | undefined {
if (typeof rawInput === "string") return rawInput;
if (typeof rawInput === "object" && rawInput !== null && !Array.isArray(rawInput)) {
const record = rawInput as Record<string, unknown>;
if (Object.keys(record).some((key) => !["command", "timeout", "timeoutMs"].includes(key))) return undefined;
return typeof record.command === "string" ? record.command : undefined;
}
return undefined;
}
function fullMatch(pattern: string, input: string): boolean {
const match = new RegExp(pattern).exec(input);
return match?.index === 0 && match[0] === input;
}
function reject(request: RequestPermissionRequest): RequestPermissionResponse { function reject(request: RequestPermissionRequest): RequestPermissionResponse {
const option = request.options.find((item) => item.kind === "reject_once") || request.options.find((item) => item.kind === "reject_always"); 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" } }; return option ? { outcome: { outcome: "selected", optionId: option.optionId } } : { outcome: { outcome: "cancelled" } };
+4 -4
View File
@@ -1,10 +1,10 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import type { AcpBackendConfig } from "../config.js"; import type { AgentConfig } from "../config.js";
import type { InitializeResponse } from "@agentclientprotocol/sdk"; import type { InitializeResponse } from "@agentclientprotocol/sdk";
import { AcpClient } from "./client.js"; import { AcpClient } from "./client.js";
export async function probeAcpBackend(backend: AcpBackendConfig, timeoutMs = 10_000): Promise<InitializeResponse> { export async function probeAcpBackend(agent: AgentConfig, timeoutMs = 10_000): Promise<InitializeResponse> {
const child = spawn(backend.command, backend.args, { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, ...backend.env } }); const child = spawn(agent.command, agent.args, { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, ...agent.env } });
const client = new AcpClient(child, { initializeTimeoutMs: timeoutMs, policy: { permissionMode: "deny", allowedTools: [], allowedCommandPatterns: [] } }); const client = new AcpClient(child, { initializeTimeoutMs: timeoutMs, policy: { mode: "deny", allowedTools: [], allowedCommandPatterns: [] } });
try { return await client.initialize(); } finally { client.close(); child.kill("SIGTERM"); } try { return await client.initialize(); } finally { client.close(); child.kill("SIGTERM"); }
} }
-150
View File
@@ -1,150 +0,0 @@
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 { ConversationRequest, ConversationResponse, ConversationRuntime, RuntimeStats } from "./types.js";
import { AcpWorker } from "./worker.js";
export class AcpSessionManager implements ConversationRuntime {
private readonly workers = new Map<string, AcpWorker>();
private readonly inFlight = new Map<string, AcpWorker>();
private readonly sweeper: NodeJS.Timeout;
private crashes = 0;
private shuttingDown = false;
constructor(
private readonly config: AcpConfig,
private readonly backends: AcpBackendRegistry,
private readonly roles: RoleRegistry,
private readonly store: DurableSessionStore
) {
this.sweeper = setInterval(() => void this.sweep(), config.sweepIntervalMs);
this.sweeper.unref();
}
async prompt(request: ConversationRequest): Promise<ConversationResponse> {
if (this.shuttingDown) throw new Error("ACP runtime is shutting down");
const chatKey = chatKeyFor(request.platform, request.chatId);
const role = this.roles.get(this.store.getSelectedRole(chatKey, this.roles.defaultId()));
let binding = this.store.getBinding(chatKey, role.id);
if (binding && (binding.roleFingerprint !== role.fingerprint || binding.backendId !== role.backend || binding.workspace !== role.workspace)) {
await this.dropBinding(binding);
binding = undefined;
}
const worker = await this.acquireWorker(role.id, binding);
this.inFlight.set(chatKey, worker);
try {
if (!binding) {
const now = Date.now();
await worker.prompt(role.bootstrap);
binding = {
chatKey, roleId: role.id, backendId: role.backend, nativeSessionId: worker.nativeSessionId!,
workspace: role.workspace, roleFingerprint: role.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 };
} catch (error) {
if (worker.nativeSessionId) this.workers.delete(workerKey(worker.backend.id, worker.nativeSessionId));
await worker.terminate();
throw error;
} finally {
if (this.inFlight.get(chatKey) === worker) this.inFlight.delete(chatKey);
}
}
async cancel(platform: string, chatId: string): Promise<boolean> {
const worker = this.inFlight.get(chatKeyFor(platform, chatId));
return worker ? worker.cancel() : false;
}
async reset(platform: string, chatId: string): Promise<void> {
const chatKey = chatKeyFor(platform, chatId);
await this.cancel(platform, chatId);
const roleId = this.store.getSelectedRole(chatKey, this.roles.defaultId());
const binding = await this.store.deleteBinding(chatKey, roleId);
if (binding) await this.stopWorker(binding.backendId, binding.nativeSessionId);
}
async selectRole(platform: string, chatId: string, roleId: string): Promise<void> {
this.roles.get(roleId);
await this.store.setSelectedRole(chatKeyFor(platform, chatId), roleId);
}
selectedRole(platform: string, chatId: string): string {
return this.store.getSelectedRole(chatKeyFor(platform, chatId), this.roles.defaultId());
}
status(platform: string, chatId: string): Record<string, string | number | boolean> {
const chatKey = chatKeyFor(platform, chatId);
const roleId = this.store.getSelectedRole(chatKey, this.roles.defaultId());
const binding = this.store.getBinding(chatKey, roleId);
return { role: roleId, backend: this.roles.get(roleId).backend, persisted: Boolean(binding), running: this.inFlight.has(chatKey) };
}
stats(): RuntimeStats {
return { activeWorkers: this.workers.size, inFlight: this.inFlight.size, crashes: this.crashes, persistedBindings: this.store.stats().bindings };
}
async shutdown(): Promise<void> {
if (this.shuttingDown) return;
this.shuttingDown = true;
clearInterval(this.sweeper);
await Promise.all([...this.inFlight.values()].map((worker) => worker.cancel().catch(() => false)));
await Promise.all([...this.workers.values()].map((worker) => worker.terminate()));
this.workers.clear();
this.inFlight.clear();
}
private async acquireWorker(roleId: string, binding?: SessionBinding): Promise<AcpWorker> {
const role = this.roles.get(roleId);
if (binding) {
const existing = this.workers.get(workerKey(binding.backendId, binding.nativeSessionId));
if (existing) return existing;
}
await this.ensureCapacity();
const worker = new AcpWorker(this.backends.get(role.backend), role, this.config, (crashed, error) => {
this.crashes++;
if (crashed.nativeSessionId) this.workers.delete(workerKey(crashed.backend.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);
return worker;
}
private async ensureCapacity(): Promise<void> {
if (this.workers.size < this.config.maxProcesses) return;
const candidate = [...this.workers.entries()].filter(([, worker]) => !worker.inFlight).sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt)[0];
if (!candidate) throw new Error(`ACP worker limit reached (${this.config.maxProcesses})`);
this.workers.delete(candidate[0]);
await candidate[1].terminate();
}
private async sweep(): Promise<void> {
const cutoff = Date.now() - this.config.idleTimeoutMs;
const expired = [...this.workers.entries()].filter(([, worker]) => !worker.inFlight && worker.lastUsedAt < cutoff);
for (const [key, worker] of expired) {
this.workers.delete(key);
await worker.terminate();
}
}
private async dropBinding(binding: SessionBinding): Promise<void> {
await this.store.deleteBinding(binding.chatKey, binding.roleId);
await this.stopWorker(binding.backendId, binding.nativeSessionId);
}
private async stopWorker(backendId: string, nativeSessionId: string): Promise<void> {
const key = workerKey(backendId, nativeSessionId);
const worker = this.workers.get(key);
if (!worker) return;
this.workers.delete(key);
await worker.terminate();
}
}
function workerKey(backendId: string, nativeSessionId: string): string { return `${backendId}:${nativeSessionId}`; }
+19 -9
View File
@@ -1,4 +1,6 @@
import type { RolePolicy } from "../config.js"; import type { PermissionPolicy } from "../config.js";
import type { Proposal } from "../core/proposal-store.js";
import type { IncomingAttachment, OutgoingImageRef } from "../core/types.js";
export interface AcpBackendSpec { export interface AcpBackendSpec {
id: string; id: string;
@@ -13,12 +15,15 @@ export interface ConversationRequest {
userId: string; userId: string;
text: string; text: string;
messageId?: string; messageId?: string;
attachments?: IncomingAttachment[];
} }
export interface ConversationResponse { export interface ConversationResponse {
text: string; text: string;
roleId: string; botId: string;
backendId: string; agentId: string;
mode?: "assistant";
images?: OutgoingImageRef[];
} }
export interface RuntimeStats { export interface RuntimeStats {
@@ -28,17 +33,22 @@ export interface RuntimeStats {
persistedBindings: number; persistedBindings: number;
} }
export type RuntimeEventSink = (chatKey: string, text: string, images?: OutgoingImageRef[]) => Promise<void>;
export interface ConversationRuntime { export interface ConversationRuntime {
prompt(request: ConversationRequest): Promise<ConversationResponse>; prompt(request: ConversationRequest): Promise<ConversationResponse>;
cancel(platform: string, chatId: string): Promise<boolean>; cancel(platform: string, chatId: string, userId: string): Promise<boolean>;
reset(platform: string, chatId: string): Promise<void>; reset(platform: string, chatId: string, userId: string): Promise<boolean>;
selectRole(platform: string, chatId: string, roleId: string): Promise<void>; status(platform: string, chatId: string, userId?: string): Record<string, string | number | boolean>;
selectedRole(platform: string, chatId: string): string;
status(platform: string, chatId: string): Record<string, string | number | boolean>;
stats(): RuntimeStats; stats(): RuntimeStats;
shutdown(): Promise<void>; shutdown(): Promise<void>;
listProposals?(platform: string, chatId: string, userId: string): Proposal[];
confirm?(platform: string, chatId: string, userId: string): Promise<boolean>;
finish?(platform: string, chatId: string, userId: string): Promise<boolean>;
stop?(platform: string, chatId: string, userId: string): Promise<boolean>;
setEventSink?(sink: RuntimeEventSink): void;
} }
export interface PermissionContext { export interface PermissionContext {
policy: RolePolicy; policy: PermissionPolicy;
} }
+163 -34
View File
@@ -1,8 +1,26 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { AcpConfig } from "../config.js"; import crypto from "node:crypto";
import type { ResolvedRole } from "../roles/role-registry.js"; import fs from "node:fs";
import { AcpClient } from "./client.js"; import type { AcpConfig, PermissionPolicy } from "../config.js";
import type { AcpBackendSpec } from "./types.js"; import type { WorkerProcessGroup } from "../core/proposal-store.js";
import type { ResolvedBot } from "../roles/role-registry.js";
import { AcpClient, type AcpPromptContent, type SafeActivityCategory } from "./client.js";
export class AcpSessionRestoreError extends Error {}
export type AcpWorkerPhase = "idle" | "initializing" | "processing";
export type AcpWorkerKind = "assistant" | "worker";
export interface AcpWorkerOptions {
kind?: AcpWorkerKind;
cwd?: string;
env?: Record<string, string>;
policy?: PermissionPolicy;
onActivity?: (category?: SafeActivityCategory) => void;
onIsolationViolation?: (worker: AcpWorker) => void;
onSpawn?: (group: WorkerProcessGroup) => Promise<void>;
allowUnverifiedAssistantAgent?: boolean;
}
export class AcpWorker { export class AcpWorker {
private child?: ChildProcessWithoutNullStreams; private child?: ChildProcessWithoutNullStreams;
@@ -10,23 +28,46 @@ export class AcpWorker {
private abort?: AbortController; private abort?: AbortController;
private exited = false; private exited = false;
private stopping = false; private stopping = false;
private termination?: Promise<void>;
private stderrBytes = 0; private stderrBytes = 0;
readonly kind: AcpWorkerKind;
readonly cwd: string;
nativeSessionId?: string; nativeSessionId?: string;
processGroup?: WorkerProcessGroup;
isolationViolated = false;
supportsImages = false;
lastUsedAt = Date.now(); lastUsedAt = Date.now();
turnStartedAt?: number;
lastActivityAt?: number;
phase: AcpWorkerPhase = "idle";
inFlight = false; inFlight = false;
constructor( constructor(
readonly backend: AcpBackendSpec, readonly bot: ResolvedBot,
readonly role: ResolvedRole,
private readonly config: AcpConfig, private readonly config: AcpConfig,
private readonly onCrash: (worker: AcpWorker, error: Error) => void private readonly onCrash: (worker: AcpWorker, error: Error) => void,
) {} private readonly options: AcpWorkerOptions = {}
) {
this.kind = options.kind || "worker";
this.cwd = options.cwd || bot.workspace;
}
static async terminatePersistedGroup(group: WorkerProcessGroup, timeoutMs: number): Promise<void> {
if (!processGroupExists(group.pgid)) return;
if (!processGroupHasToken(group.pgid, group.token)) {
throw new Error(`Persisted ACP process group ${group.pgid} no longer matches its worker token`);
}
signalProcessGroup(group.pgid, "SIGKILL");
await waitForProcessGroupExit(group.pgid, timeoutMs);
}
async start(nativeSessionId?: string): Promise<string> { async start(nativeSessionId?: string): Promise<string> {
this.child = spawn(this.backend.command, this.backend.args, { const processToken = crypto.randomUUID();
cwd: this.role.workspace, this.child = spawn(this.bot.agent.command, this.bot.agent.args, {
env: { ...process.env, ...this.backend.env }, cwd: this.cwd,
env: { ...process.env, ...this.bot.agent.env, ...this.options.env, GORI_AGENT_WORKER_TOKEN: processToken },
shell: false, shell: false,
detached: true,
stdio: ["pipe", "pipe", "pipe"] stdio: ["pipe", "pipe", "pipe"]
}); });
this.child.stderr.on("data", (chunk: Buffer) => this.logStderr(chunk)); this.child.stderr.on("data", (chunk: Buffer) => this.logStderr(chunk));
@@ -35,27 +76,52 @@ export class AcpWorker {
this.exited = true; this.exited = true;
if (!this.stopping) this.crashed(new Error(`ACP worker exited (code=${code ?? "null"}, signal=${signal ?? "null"})`)); 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.options.policy || this.bot.permissions,
forbidToolActivity: this.kind === "assistant",
onToolViolation: () => {
this.isolationViolated = true;
this.options.onIsolationViolation?.(this);
this.terminateImmediately();
},
onSessionActivity: (category) => {
this.lastActivityAt = Date.now();
this.options.onActivity?.(category);
}
});
try { try {
await this.client.initialize(); if (!this.child.pid) throw new Error("ACP worker has no process ID");
this.processGroup = { pgid: this.child.pid, token: processToken };
await this.options.onSpawn?.(this.processGroup);
const initialized = await this.client.initialize();
this.supportsImages = this.client.supportsImageInput();
if (this.kind === "assistant" && !this.options.allowUnverifiedAssistantAgent && initialized.agentInfo?.name !== "Kimi Code CLI") {
throw new Error("Assistant sessions require Kimi Code ACP with execution-layer no-tool profiles");
}
if (nativeSessionId) { if (nativeSessionId) {
await this.client.resumeSession(nativeSessionId, this.role.workspace); await this.client.resumeSession(nativeSessionId, this.cwd);
this.nativeSessionId = nativeSessionId; this.nativeSessionId = nativeSessionId;
} else { } else {
this.nativeSessionId = await this.client.newSession(this.role.workspace); this.nativeSessionId = await this.client.newSession(this.cwd);
} }
return this.nativeSessionId; return this.nativeSessionId;
} catch (error) { } catch (error) {
await this.terminate(); await this.terminate();
if (nativeSessionId) throw new AcpSessionRestoreError(`Cannot restore ACP session '${nativeSessionId}': ${error instanceof Error ? error.message : String(error)}`);
throw error; throw error;
} }
} }
async prompt(text: string): Promise<string> { async prompt(input: string | AcpPromptContent[], phase: Exclude<AcpWorkerPhase, "idle"> = "processing"): Promise<string> {
if (!this.client || !this.nativeSessionId || this.exited) throw new Error("ACP worker is not available"); if (!this.client || !this.nativeSessionId || this.exited) throw new Error("ACP worker is not available");
if (this.inFlight) throw new Error("ACP worker already has an in-flight turn"); if (this.inFlight) throw new Error("ACP worker already has an in-flight turn");
const now = Date.now();
this.inFlight = true; this.inFlight = true;
this.lastUsedAt = Date.now(); this.phase = phase;
this.turnStartedAt = now;
this.lastActivityAt = now;
this.lastUsedAt = now;
this.abort = new AbortController(); this.abort = new AbortController();
let timeout: NodeJS.Timeout | undefined; let timeout: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<never>((_resolve, reject) => { const timeoutPromise = new Promise<never>((_resolve, reject) => {
@@ -67,10 +133,15 @@ export class AcpWorker {
}, this.config.promptTimeoutMs); }, this.config.promptTimeoutMs);
}); });
try { try {
return await Promise.race([this.client.prompt(text, this.abort.signal), timeoutPromise]); const result = await Promise.race([this.client.prompt(input, this.abort.signal), timeoutPromise]);
if (this.kind === "assistant") await this.client.settleIsolation();
return result;
} finally { } finally {
if (timeout) clearTimeout(timeout); if (timeout) clearTimeout(timeout);
this.inFlight = false; this.inFlight = false;
this.phase = "idle";
this.turnStartedAt = undefined;
this.lastActivityAt = undefined;
this.abort = undefined; this.abort = undefined;
this.lastUsedAt = Date.now(); this.lastUsedAt = Date.now();
} }
@@ -84,24 +155,31 @@ export class AcpWorker {
return true; return true;
} }
async terminate(): Promise<void> { terminateImmediately(): void {
if (this.stopping) return;
this.stopping = true; this.stopping = true;
if (this.client && !this.exited) { this.abort?.abort();
await Promise.race([ this.client?.close(new Error("ACP worker terminated because workspace ownership was lost"));
this.client.closeSession().catch(() => undefined), if (this.child) killProcessGroup(this.child, "SIGKILL");
new Promise<void>((resolve) => setTimeout(resolve, this.config.cancelGraceMs)) this.termination ||= this.finishTermination("SIGKILL");
]);
} }
terminate(): Promise<void> {
if (this.termination) return this.termination;
this.stopping = true;
this.abort?.abort();
this.client?.close(); this.client?.close();
if (this.child && !this.exited) { this.termination = this.finishTermination("SIGTERM");
this.child.kill("SIGTERM"); return this.termination;
await waitForExit(this.child, this.config.cancelGraceMs);
if (!this.exited) {
this.child.kill("SIGKILL");
await waitForExit(this.child, this.config.cancelGraceMs);
}
} }
private async finishTermination(initialSignal: NodeJS.Signals): Promise<void> {
if (!this.child?.pid) return;
const pid = this.child.pid;
killProcessGroup(this.child, initialSignal);
await waitForExit(this.child, this.config.cancelGraceMs);
killProcessGroup(this.child, "SIGKILL");
await waitForExit(this.child, this.config.cancelGraceMs);
await waitForProcessGroupExit(pid, this.config.cancelGraceMs);
} }
private logStderr(chunk: Buffer): void { private logStderr(chunk: Buffer): void {
@@ -109,16 +187,49 @@ export class AcpWorker {
if (!remaining) return; if (!remaining) return;
const text = chunk.subarray(0, remaining).toString("utf8").trimEnd(); const text = chunk.subarray(0, remaining).toString("utf8").trimEnd();
this.stderrBytes += Buffer.byteLength(text); this.stderrBytes += Buffer.byteLength(text);
if (text) console.error(`[acp:${this.backend.id}] ${text}`); if (text) console.error(`[acp:${this.kind}:${this.bot.agent.id}] ${text}`);
} }
private crashed(error: Error): void { private crashed(error: Error): void {
if (this.stopping) return; if (this.stopping) return;
this.stopping = true; this.stopping = true;
this.onCrash(this, error); this.abort?.abort();
this.client?.close(error);
this.termination = this.finishTermination("SIGKILL");
void this.termination.then(
() => this.onCrash(this, error),
(cleanupError) => this.onCrash(this, new Error(`${error.message}; process-group cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`))
);
} }
} }
function killProcessGroup(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): void {
if (child.pid) signalProcessGroup(child.pid, signal);
}
function signalProcessGroup(pgid: number, signal: NodeJS.Signals): void {
try { process.kill(-pgid, signal); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; }
}
function processGroupHasToken(pgid: number, token: string): boolean {
for (const entry of fs.readdirSync("/proc")) {
if (!/^\d+$/.test(entry)) continue;
try {
const stat = fs.readFileSync(`/proc/${entry}/stat`, "utf8");
const close = stat.lastIndexOf(")");
const fields = stat.slice(close + 2).split(" ");
if (Number(fields[2]) !== pgid) continue;
const environ = fs.readFileSync(`/proc/${entry}/environ`);
if (environ.toString("utf8").split("\0").includes(`GORI_AGENT_WORKER_TOKEN=${token}`)) return true;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT" && code !== "EACCES" && code !== "EPERM") throw error;
}
}
return false;
}
function waitForExit(child: ChildProcessWithoutNullStreams, timeoutMs: number): Promise<void> { function waitForExit(child: ChildProcessWithoutNullStreams, timeoutMs: number): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(); if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -126,3 +237,21 @@ function waitForExit(child: ChildProcessWithoutNullStreams, timeoutMs: number):
child.once("close", () => { clearTimeout(timer); resolve(); }); child.once("close", () => { clearTimeout(timer); resolve(); });
}); });
} }
async function waitForProcessGroupExit(pid: number, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (processGroupExists(pid) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
if (processGroupExists(pid)) throw new Error(`ACP process group ${pid} did not terminate`);
}
function processGroupExists(pid: number): boolean {
try { process.kill(-pid, 0); return true; }
catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ESRCH") return false;
if (code === "EPERM") return true;
throw error;
}
}
+30 -69
View File
@@ -1,85 +1,46 @@
#!/usr/bin/env node #!/usr/bin/env node
import process from "node:process"; import process from "node:process";
import { discoverBackends } from "./acp/discovery.js"; import { runInstanceCommand } from "./cli/instance.js";
import { loadConfigFile } from "./cli/config-file.js";
import { runDoctor } from "./cli/doctor.js";
import { localBaseUrl } from "./cli/net.js";
import { printFeishu } from "./cli/print.js";
import { runSetup } from "./cli/setup.js";
import { defaultStateFile } from "./config.js";
import { startServer } from "./server.js";
interface ParsedArgs { command?: string; rest: string[]; configPath?: string; json: boolean; help: boolean } const COMMANDS = ["init", "setup", "start", "stop", "restart", "status", "logs", "doctor", "list"] as const;
type Command = typeof COMMANDS[number];
async function main(argv: string[]): Promise<number> { async function main(argv: string[]): Promise<number> {
const parsed = parseArgs(argv); if (argv.length === 0 || (argv.length === 1 && ["--help", "-h"].includes(argv[0]))) {
if (parsed.help || !parsed.command) { printHelp(); return 0; } printHelp();
if (parsed.command === "setup") { await runSetup(parsed.configPath); return 0; }
if (parsed.command === "discover-backends" || parsed.command === "discover-agents") {
const backends = await discoverBackends();
if (parsed.json) console.log(JSON.stringify(backends, null, 2));
else for (const backend of backends) {
const version = backend.version ? ` (${backend.version})` : "";
const reason = backend.reason ? ` - ${backend.reason}` : "";
console.log(`${backend.id}\t${backend.status}\t${backend.command} ${backend.args.join(" ")}${version}${reason}`.trim());
}
return 0; return 0;
} }
if (parsed.command === "start") {
const loaded = loadConfigFile(parsed.configPath);
const running = await startServer(loaded.config);
return await new Promise<number>((resolve) => {
let stopping = false;
const stop = (signal: string): void => {
if (stopping) return;
stopping = true;
console.log(`Received ${signal}; shutting down...`);
void running.shutdown().then(() => resolve(0), (error) => { console.error(error); resolve(1); });
};
process.once("SIGINT", () => stop("SIGINT"));
process.once("SIGTERM", () => stop("SIGTERM"));
});
}
if (parsed.command === "status") { await printStatus(parsed.configPath); return 0; }
if (parsed.command === "doctor") { const loaded = loadConfigFile(parsed.configPath); return runDoctor(loaded.config, loaded.path); }
if (parsed.command === "print") {
const loaded = loadConfigFile(parsed.configPath);
if (parsed.rest[0] === "feishu") { await printFeishu(loaded.config); return 0; }
console.error(`Unknown print topic: ${parsed.rest[0] || "(missing)"}`); return 1;
}
console.error(`Unknown command: ${parsed.command}`); printHelp(); return 1;
}
function parseArgs(argv: string[]): ParsedArgs { const [command, ...args] = argv;
const rest: string[] = []; let command: string | undefined; let configPath: string | undefined; let json = false; let help = false; const option = argv.find((arg) => arg.startsWith("-"));
for (let index = 0; index < argv.length; index++) { if (option) throw new Error(`Unknown option: ${option}`);
const arg = argv[index]; if (!COMMANDS.includes(command as Command)) throw new Error(`Unknown command: ${command}`);
if (arg === "--help" || arg === "-h") help = true; if (command === "list") {
else if (arg === "--json") json = true; if (args.length !== 0) throw new Error("list does not accept <bot-id>");
else if (arg === "--config") { configPath = argv[++index]; if (!configPath) throw new Error("--config requires a path"); } } else if (args.length !== 1) {
else if (!command) command = arg; else rest.push(arg); throw new Error(`${command} requires exactly one <bot-id>`);
} }
return { command, rest, configPath, json, help }; return runInstanceCommand(command, args);
}
async function printStatus(configPath?: string): Promise<void> {
const loaded = loadConfigFile(configPath); const baseUrl = localBaseUrl(loaded.config);
console.log(`Config: ${loaded.path}${loaded.exists ? "" : " (seeded from config.example.json)"}`);
console.log(`Server: ${loaded.config.server.host}:${loaded.config.server.port}`);
console.log(`Default role: ${loaded.config.defaultRole}`);
console.log(`Roles: ${loaded.config.roles.map(({ id }) => id).join(", ")}`);
console.log(`Backends: ${loaded.config.backends.map(({ id }) => id).join(", ")}`);
console.log(`State file: ${defaultStateFile(loaded.config)}`);
try {
const response = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(1_000) });
console.log(`Health probe: HTTP ${response.status} ${await response.text()}`);
} catch { console.log("Health probe: not reachable on local URL"); }
} }
function printHelp(): void { function printHelp(): void {
console.log(`gori-agent - multi-IM ACP agent gateway\n\nUsage:\n gori-agent setup [--config path]\n gori-agent discover-backends [--json]\n gori-agent start [--config path]\n gori-agent status [--config path]\n gori-agent doctor [--config path]\n gori-agent print feishu [--config path]\n\nDeprecated alias: discover-agents`); console.log(`gori-agent - single-Bot ACP gateway
Usage:
gori-agent init <bot-id>
gori-agent setup <bot-id>
gori-agent start <bot-id>
gori-agent stop <bot-id>
gori-agent restart <bot-id>
gori-agent status <bot-id>
gori-agent logs <bot-id>
gori-agent doctor <bot-id>
gori-agent list
Instances root: \${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances`);
} }
main(process.argv.slice(2)).then((code) => { if (Number.isInteger(code)) process.exitCode = code; }).catch((error) => { main(process.argv.slice(2)).then((code) => { if (Number.isInteger(code)) process.exitCode = code; }).catch((error) => {
console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}); });
+104 -36
View File
@@ -1,3 +1,4 @@
import crypto from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import process from "node:process"; import process from "node:process";
@@ -7,60 +8,127 @@ import { loadConfigFromPath, parseConfig } from "../config.js";
export interface LoadedConfigFile { export interface LoadedConfigFile {
path: string; path: string;
config: AppConfig; config: AppConfig;
exists: boolean; source: "explicit" | "env" | "local";
source: "explicit" | "env" | "local" | "example";
} }
export function projectRoot(): string { export function projectRoot(): string {
return path.resolve(new URL("../..", import.meta.url).pathname); return path.resolve(new URL("../..", import.meta.url).pathname);
} }
export function resolveCliConfigPath(configPath?: string): { path: string; exists: boolean; source: LoadedConfigFile["source"] } { export function resolveCliConfigPath(configPath?: string): { path: string; source: LoadedConfigFile["source"] } {
if (configPath) { if (configPath) return { path: path.resolve(configPath), source: "explicit" };
const resolved = path.resolve(configPath); if (process.env.GORI_GATEWAY_CONFIG) return { path: path.resolve(process.env.GORI_GATEWAY_CONFIG), source: "env" };
return { path: resolved, exists: fs.existsSync(resolved), source: "explicit" }; return { path: path.resolve("config.json"), source: "local" };
}
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 loadConfigFile(configPath?: string): LoadedConfigFile { export function loadConfigFile(configPath?: string): LoadedConfigFile {
const resolved = resolveCliConfigPath(configPath); const resolved = resolveCliConfigPath(configPath);
if (resolved.exists) { if (!fs.existsSync(resolved.path)) {
return { throw new Error(`Runtime config does not exist: ${resolved.path}; config.example.json is documentation only`);
path: resolved.path, }
config: loadConfigFromPath(resolved.path), return { path: resolved.path, config: loadConfigFromPath(resolved.path), source: resolved.source };
exists: true,
source: resolved.source
};
} }
export function loadExampleConfig(): AppConfig {
const examplePath = path.join(projectRoot(), "config.example.json"); const examplePath = path.join(projectRoot(), "config.example.json");
const raw = fs.readFileSync(examplePath, "utf8"); return parseConfig(JSON.parse(fs.readFileSync(examplePath, "utf8")) as unknown);
const config = parseConfig(JSON.parse(raw) as unknown); }
return {
path: resolved.path, export function configDigest(config: AppConfig): string {
config, return crypto.createHash("sha256").update(JSON.stringify(config)).digest("hex");
exists: false,
source: "example"
};
} }
export function writeConfigFile(configPath: string, config: AppConfig): void { export function writeConfigFile(configPath: string, config: AppConfig): void {
const parsed = parseConfig(config); const parsed = parseConfig(config);
fs.mkdirSync(path.dirname(configPath), { recursive: true }); const directory = path.dirname(configPath);
fs.writeFileSync(configPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8"); 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");
if (config.bot.agent.args.length !== 1 || config.bot.agent.args[0] !== "acp") problems.push("bot.agent.args must be exactly ['acp'] so the no-tool side profile cannot be bypassed");
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 { export function readConfigJson(configPath?: string): unknown {
const loaded = loadConfigFile(configPath); return loadConfigFile(configPath).config;
return loaded.config;
} }
+34 -32
View File
@@ -2,61 +2,63 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { probeAcpBackend } from "../acp/probe.js"; import { probeAcpBackend } from "../acp/probe.js";
import { defaultStateFile, type AppConfig } from "../config.js"; import { defaultStateFile, type AppConfig } from "../config.js";
import { RoleRegistry } from "../roles/role-registry.js"; import { BotProfileResolver } from "../roles/role-registry.js";
import { findOverlappingWorkspace } from "../core/workspace-scope.js";
import { operationalConfigProblems } from "./config-file.js";
import { printUrlHints } from "./net.js"; import { printUrlHints } from "./net.js";
export async function runDoctor(config: AppConfig, configPath: string): Promise<number> { export async function runDoctor(config: AppConfig, configPath: string): Promise<number> {
let problems = 0; let problems = 0;
console.log(`Config: ${configPath}`); 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 { try {
new RoleRegistry(config); const mode = fs.statSync(configPath).mode & 0o777;
console.log(`Default role: ${config.defaultRole}`); if (mode !== 0o600) problems += reportError(`config file mode must be 0600, got 0${mode.toString(8)}`);
} catch (error) { } 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)); 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.`);
}
}
for (const backend of config.backends) {
try { try {
const initialized = await probeAcpBackend(backend, config.acp.initializeTimeoutMs); const instancesDirectory = path.dirname(path.dirname(configPath));
const capabilities = initialized.agentCapabilities; const conflict = findOverlappingWorkspace(config.bot.workspace, config.bot.id, instancesDirectory);
console.log(`ACP '${backend.id}': ready (${initialized.agentInfo?.name || "unknown"} ${initialized.agentInfo?.version || ""})`); if (conflict) problems += reportError(`bot '${conflict}' declares an identical, parent, or child workspace; workspace scopes must be disjoint`);
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) { } catch (error) {
problems += reportError(`ACP '${backend.id}' initialize failed: ${error instanceof Error ? error.message : String(error)}`); problems += reportError(`cannot validate peer workspaces: ${error instanceof Error ? error.message : String(error)}`);
} }
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.");
}
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); const stateFile = defaultStateFile(config);
try { try {
const stateDirectory = path.dirname(stateFile); 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); fs.accessSync(stateDirectory, fs.constants.R_OK | fs.constants.W_OK);
console.log(`State file: ${stateFile}`); console.log(`State file: ${stateFile}`);
} catch (error) { } catch (error) {
problems += reportError(`state directory is unavailable: ${error instanceof Error ? error.message : String(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); await printUrlHints(config);
return problems > 0 ? 1 : 0; return problems > 0 ? 1 : 0;
} }
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env node
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
import type { AppConfig } from "../config.js";
import { findOverlappingWorkspace } from "../core/workspace-scope.js";
import { startServer } from "../server.js";
import { assertOperationalConfig, configDigest, loadConfigFile } from "./config-file.js";
interface ShutdownTarget { shutdown(): Promise<void> }
interface SignalEmitter {
once(signal: "SIGINT" | "SIGTERM", listener: () => void): unknown;
removeListener(signal: "SIGINT" | "SIGTERM", listener: () => void): unknown;
}
export async function runInstanceRunner(argv: string[]): Promise<number> {
if (argv.length !== 1) throw new Error("instance-runner requires exactly one config path");
const loaded = loadConfigFile(argv[0]);
assertOperationalConfig(loaded.config, loaded.path);
assertRunnerConfigSnapshot(loaded.config);
assertRunnerWorkspaceSafety(loaded.config, loaded.path);
return waitForShutdown(await startServer(loaded.config));
}
export function assertRunnerConfigSnapshot(config: AppConfig, expected = process.env.GORI_AGENT_CONFIG_DIGEST): void {
if (!expected || expected !== configDigest(config)) {
throw new Error("instance config changed after start validation; refusing to launch");
}
}
export function assertRunnerWorkspaceSafety(config: AppConfig, configFile: string, root = agentRoot()): void {
const expected = path.join(root, "instances", config.bot.id, "config.json");
if (path.resolve(configFile) !== expected) throw new Error("instance-runner config path does not match the instance directory contract");
const conflict = findOverlappingWorkspace(config.bot.workspace, config.bot.id, path.join(root, "instances"));
if (conflict) throw new Error(`Refusing to start: bot '${conflict}' declares an identical, parent, or child workspace; workspace scopes must be disjoint`);
}
function agentRoot(): string {
return path.resolve(process.env.GORI_AGENT_ROOT || path.join(os.homedir(), ".gori-agent"));
}
export function waitForShutdown(running: ShutdownTarget, signals: SignalEmitter = process): Promise<number> {
return new Promise<number>((resolve) => {
let stopping = false;
const finish = (code: number): void => {
signals.removeListener("SIGINT", onSigint);
signals.removeListener("SIGTERM", onSigterm);
resolve(code);
};
const stop = (signal: string): void => {
if (stopping) return;
stopping = true;
console.log(`Received ${signal}; shutting down...`);
void running.shutdown().then(() => finish(0), (error) => {
console.error(error);
finish(1);
});
};
const onSigint = (): void => stop("SIGINT");
const onSigterm = (): void => stop("SIGTERM");
signals.once("SIGINT", onSigint);
signals.once("SIGTERM", onSigterm);
});
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
runInstanceRunner(process.argv.slice(2)).then((code) => { process.exitCode = code; }).catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}
+446
View File
@@ -0,0 +1,446 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { defaultStateFile } from "../config.js";
import { findOverlappingWorkspace } from "../core/workspace-scope.js";
import { assertOperationalConfig, configDigest, 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})$/;
const LIFECYCLE_FLOCK = "/usr/bin/flock";
export function agentRoot(): string {
return path.resolve(process.env.GORI_AGENT_ROOT || path.join(os.homedir(), ".gori-agent"));
}
export function instancesRoot(): string { return path.join(agentRoot(), "instances"); }
export function instanceDirectory(botId: string): string {
validateBotId(botId);
return path.join(instancesRoot(), botId);
}
export function instanceConfigPath(botId: string): string { return path.join(instanceDirectory(botId), "config.json"); }
export async function runInstanceCommand(action: string | undefined, args: string[]): Promise<number> {
if (action === "list") {
if (args.length !== 0) throw new Error("list does not accept <bot-id>");
return listInstances();
}
if (!action || !["init", "setup", "start", "stop", "restart", "status", "logs", "doctor"].includes(action)) {
throw new Error(`Unknown command: ${action || "(missing)"}`);
}
if (args.length !== 1) throw new Error(`${action} requires exactly one <bot-id>`);
const botId = args[0];
validateBotId(botId);
switch (action) {
case "init": return initInstance(botId);
case "setup": return setupInstance(botId);
case "start": return startInstance(botId);
case "stop": return stopInstance(botId);
case "restart": await stopInstance(botId); return startInstance(botId);
case "status": return statusInstance(botId);
case "logs": return logsInstance(botId);
case "doctor": return doctorInstance(botId);
default: throw new Error(`Unknown command: ${action}`);
}
}
async function initInstance(botId: string): Promise<number> {
const directory = instanceDirectory(botId);
if (fs.existsSync(directory)) throw new Error(`Instance already exists: ${botId}`);
ensureInstanceDirectories(directory, true);
try {
await runSetup(path.join(directory, "config.json"), { botId, requireNew: true, writeWithoutConfirmation: true });
return 0;
} catch (error) {
cleanupFailedInit(directory);
throw error;
}
}
async function setupInstance(botId: string): Promise<number> {
loadInstance(botId);
const paths = runtimePaths(botId);
ensureInstanceDirectories(paths.home, false);
return withInstanceLifecycleLock(paths.home, async () => {
const loaded = loadInstance(botId);
assertConfigMode(loaded.path);
const current = inspectPid(paths.pidFile, loaded.path);
assertSetupPidSafe(botId, current);
if (current.kind === "stale") removeStalePid(paths.pidFile, current);
const written = await runSetup(loaded.path, { botId, instancesDirectory: instancesRoot() });
if (!written) return 0;
return doctorInstance(botId);
});
}
async function startInstance(botId: string): Promise<number> {
loadInstance(botId);
const paths = runtimePaths(botId);
ensureInstanceDirectories(paths.home, false);
return withInstanceLifecycleLock(paths.home, async () => {
const loaded = loadInstance(botId);
assertOperationalConfig(loaded.config, loaded.path);
assertNoOverlappingWorkspace(loaded.config.bot.workspace, botId);
fs.accessSync(LIFECYCLE_FLOCK, fs.constants.X_OK);
const current = inspectPid(paths.pidFile, loaded.path);
if (current.kind === "running") throw new Error(`Instance '${botId}' is already running (pid ${current.pid})`);
if (current.kind === "foreign") throw new Error(`Refusing to start: PID file references unrelated live process ${current.pid}`);
if (current.kind === "stale") removeStalePid(paths.pidFile, current);
await assertPortAvailable(loaded.config.gateway.server.host, loaded.config.gateway.server.port);
const logFd = fs.openSync(paths.logFile, "a", 0o600);
fs.chmodSync(paths.logFile, 0o600);
const runnerFile = instanceRunnerPath();
let child: ReturnType<typeof spawn> | undefined;
try {
child = spawn(process.execPath, [runnerFile, loaded.path], {
cwd: paths.home,
env: {
...process.env,
GORI_AGENT_HOME: paths.home,
GORI_AGENT_CONFIG_DIGEST: configDigest(loaded.config)
},
detached: true,
stdio: ["ignore", logFd, logFd]
});
await new Promise<void>((resolve, reject) => {
child!.once("spawn", resolve);
child!.once("error", reject);
});
if (!child.pid) throw new Error("Instance child process has no PID");
atomicWriteMode(paths.pidFile, `${child.pid}\n`, 0o600);
await waitForHealthyInstance(child.pid, loaded.config.gateway.platform.type, botId, loaded.config);
child.unref();
console.log(`Instance '${botId}' started (pid ${child.pid})`);
console.log(`Log: ${paths.logFile}`);
return 0;
} catch (error) {
if (child?.pid) {
await terminateStartedChild(child.pid);
removePidIfOwned(paths.pidFile, child.pid);
}
throw new Error(`Instance '${botId}' failed to start; inspect ${paths.logFile}: ${error instanceof Error ? error.message : String(error)}`);
} finally { fs.closeSync(logFd); }
});
}
async function stopInstance(botId: string): Promise<number> {
const loaded = loadInstance(botId);
const paths = runtimePaths(botId);
const current = inspectPid(paths.pidFile, loaded.path);
if (current.kind === "foreign") throw new Error(`Refusing to stop: PID file references unrelated live process ${current.pid}`);
if (current.kind !== "running") {
if (current.kind === "stale") removeStalePid(paths.pidFile, current);
console.log(`Instance '${botId}' is not running`);
return 0;
}
process.kill(current.pid, "SIGTERM");
for (let attempt = 0; attempt < 100 && isPidRunning(current.pid); attempt++) await delay(100);
if (isPidRunning(current.pid)) throw new Error(`Instance '${botId}' did not stop within 10 seconds (pid ${current.pid})`);
removeStalePid(paths.pidFile);
console.log(`Instance '${botId}' stopped (pid ${current.pid})`);
return 0;
}
async function statusInstance(botId: string): Promise<number> {
const loaded = loadInstance(botId);
const paths = runtimePaths(botId);
const current = inspectPid(paths.pidFile, loaded.path);
const pid = current.kind === "running" ? current.pid : undefined;
console.log(`Instance: ${botId}`);
console.log(`Process: ${pid ? `running (pid ${pid})` : current.kind === "foreign" ? `PID identity mismatch (${current.pid})` : "not running"}`);
console.log(`Config: ${loaded.path}`);
console.log(`Platform: ${loaded.config.gateway.platform.type}`);
console.log(`Agent: ${loaded.config.bot.agent.id}`);
console.log(`Workspace: ${loaded.config.bot.workspace}`);
console.log(`State file: ${withInstanceHome(paths.home, () => defaultStateFile(loaded.config))}`);
if (current.kind === "foreign") return 1;
if (pid) {
try {
const response = await fetch(`${localBaseUrl(loaded.config)}/health`, { signal: AbortSignal.timeout(1_000) });
const health = await response.json() as { botId?: string; platform?: string };
const matches = response.ok && health.botId === botId && health.platform === loaded.config.gateway.platform.type;
console.log(`Health: HTTP ${response.status}, identity ${matches ? "verified" : "mismatch"}`);
if (!matches) return 1;
} catch { console.log("Health: not reachable on local URL"); return 1; }
}
return 0;
}
async function logsInstance(botId: string): Promise<number> {
loadInstance(botId);
const paths = runtimePaths(botId);
ensurePrivateDirectory(path.dirname(paths.logFile), true);
const fd = fs.openSync(paths.logFile, "a", 0o600);
fs.closeSync(fd);
fs.chmodSync(paths.logFile, 0o600);
return new Promise<number>((resolve, reject) => {
const child = spawn("tail", ["-f", paths.logFile], { stdio: "inherit" });
child.once("error", reject);
child.once("close", (code) => resolve(code || 0));
});
}
async function doctorInstance(botId: string): Promise<number> {
const loaded = loadInstance(botId);
return withInstanceHomeAsync(instanceDirectory(botId), () => runDoctor(loaded.config, loaded.path));
}
function listInstances(): number {
const root = instancesRoot();
if (!fs.existsSync(root)) return 0;
ensurePrivateDirectory(root, false);
for (const entry of fs.readdirSync(root, { withFileTypes: true }).filter((item) => item.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
if (!BOT_ID_PATTERN.test(entry.name)) continue;
const configFile = path.join(root, entry.name, "config.json");
let state = "missing-config";
if (fs.existsSync(configFile)) {
const current = inspectPid(path.join(root, entry.name, "state", "gori-agent.pid"), configFile);
state = current.kind === "running" ? "running" : current.kind === "foreign" ? "pid-mismatch" : "stopped";
}
console.log(`${entry.name}\t${state}`);
}
return 0;
}
function loadInstance(botId: string): ReturnType<typeof loadConfigFile> {
const configFile = instanceConfigPath(botId);
if (fs.existsSync(path.dirname(configFile))) ensurePrivateDirectory(path.dirname(configFile), false);
if (fs.existsSync(configFile)) assertConfigMode(configFile);
const loaded = loadConfigFile(configFile);
if (loaded.config.bot.id !== botId) throw new Error(`Instance directory '${botId}' does not match config bot.id '${loaded.config.bot.id}'`);
return loaded;
}
function runtimePaths(botId: string): { home: string; pidFile: string; logFile: string } {
const home = instanceDirectory(botId);
return { home, pidFile: path.join(home, "state", "gori-agent.pid"), logFile: path.join(home, "logs", "gori-agent.log") };
}
function ensureInstanceDirectories(home: string, createHome: boolean): void {
ensurePrivateDirectory(agentRoot(), createHome);
ensurePrivateDirectory(instancesRoot(), createHome);
ensurePrivateDirectory(home, createHome);
ensurePrivateDirectory(path.join(home, "state"), true);
ensurePrivateDirectory(path.join(home, "logs"), true);
}
function ensurePrivateDirectory(directory: string, create: boolean): void {
if (!fs.existsSync(directory)) {
if (!create) throw new Error(`Required directory does not exist: ${directory}`);
fs.mkdirSync(directory, { mode: 0o700 });
}
const stat = fs.lstatSync(directory);
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Refusing unsafe instance path: ${directory}`);
const mode = stat.mode & 0o777;
if (mode !== 0o700) throw new Error(`Directory mode must be 0700: ${directory} has 0${mode.toString(8)}`);
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) throw new Error(`Directory is not owned by the current user: ${directory}`);
}
function cleanupFailedInit(directory: string): void {
if (fs.existsSync(path.join(directory, "config.json"))) return;
for (const name of ["state", "logs"]) {
try { fs.rmdirSync(path.join(directory, name)); } catch { /* preserve non-empty or absent directories */ }
}
try { fs.rmdirSync(directory); } catch { /* preserve non-empty directory */ }
}
function validateBotId(botId: string): void {
if (!BOT_ID_PATTERN.test(botId)) throw new Error(`Invalid bot ID '${botId}'; use lowercase letters, digits, and hyphens`);
}
function assertConfigMode(configFile: string): void {
const stat = fs.lstatSync(configFile);
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Config must be a regular file");
const mode = stat.mode & 0o777;
if (mode !== 0o600) throw new Error(`Config file mode must be 0600, got 0${mode.toString(8)}`);
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) throw new Error("Config file is not owned by the current user");
}
export function instanceRunnerPath(): string {
const moduleFile = fileURLToPath(import.meta.url);
const projectRoot = path.resolve(path.dirname(moduleFile), "../..");
return path.join(projectRoot, "dist", "cli", "instance-runner.js");
}
export function matchesInstanceRunner(commandLine: string[], executable: string, configFile: string): boolean {
if (commandLine.length !== 3) return false;
try {
return fs.realpathSync(executable) === fs.realpathSync(process.execPath)
&& path.resolve(commandLine[1]) === instanceRunnerPath()
&& path.resolve(commandLine[2]) === path.resolve(configFile);
} catch { return false; }
}
type PidInspection = { kind: "absent" } | { kind: "stale"; dev?: number; ino?: number } | { kind: "running" | "foreign"; pid: number };
export function assertSetupPidSafe(botId: string, current: PidInspection): void {
if (current.kind === "running") throw new Error(`Refusing to setup: instance '${botId}' is running (pid ${current.pid})`);
if (current.kind === "foreign") throw new Error(`Refusing to setup: PID file references unrelated live process ${current.pid}`);
}
function inspectPid(pidFile: string, configFile: string): PidInspection {
let text: string;
let stat: fs.Stats;
try {
stat = fs.lstatSync(pidFile);
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`PID file must be a regular file: ${pidFile}`);
const mode = stat.mode & 0o777;
if (mode !== 0o600) throw new Error(`PID file mode must be 0600: ${pidFile} has 0${mode.toString(8)}`);
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) throw new Error(`PID file is not owned by the current user: ${pidFile}`);
text = fs.readFileSync(pidFile, "utf8").trim();
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { kind: "absent" };
throw error;
}
const pid = Number(text);
if (!Number.isSafeInteger(pid) || pid <= 1 || !isPidRunning(pid)) return { kind: "stale", dev: stat.dev, ino: stat.ino };
try {
const commandLine = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").filter(Boolean);
const executable = fs.readlinkSync(`/proc/${pid}/exe`);
return { kind: matchesInstanceRunner(commandLine, executable, configFile) ? "running" : "foreign", pid };
} catch { return isPidRunning(pid) ? { kind: "foreign", pid } : { kind: "stale", dev: stat.dev, ino: stat.ino }; }
}
function isPidRunning(pid: number | undefined): boolean {
if (!pid) return false;
try { process.kill(pid, 0); return true; }
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
}
function removeStalePid(pidFile: string, inspection?: Extract<PidInspection, { kind: "stale" }>): void {
try {
if (inspection?.dev !== undefined && inspection.ino !== undefined) {
const current = fs.lstatSync(pidFile);
if (current.dev !== inspection.dev || current.ino !== inspection.ino) return;
}
fs.unlinkSync(pidFile);
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
}
function removePidIfOwned(pidFile: string, pid: number): void {
try {
if (fs.readFileSync(pidFile, "utf8").trim() === String(pid)) fs.unlinkSync(pidFile);
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
}
function atomicWriteMode(file: string, content: string, mode: number): void {
const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
let fd: number | undefined;
try {
fd = fs.openSync(temp, "wx", mode);
fs.writeFileSync(fd, content, "utf8");
fs.fsyncSync(fd);
fs.closeSync(fd);
fd = undefined;
fs.linkSync(temp, file);
fs.unlinkSync(temp);
fs.chmodSync(file, mode);
const dirFd = fs.openSync(path.dirname(file), "r");
try { fs.fsyncSync(dirFd); } finally { fs.closeSync(dirFd); }
} catch (error) {
if (fd !== undefined) try { fs.closeSync(fd); } catch { /* ignore cleanup failure */ }
try { fs.unlinkSync(temp); } catch { /* ignore cleanup failure */ }
throw error;
}
}
function assertPortAvailable(host: string, port: number): Promise<void> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", (error: NodeJS.ErrnoException) => reject(new Error(`Cannot start: ${host}:${port} is unavailable (${error.code || error.message})`)));
server.listen(port, host, () => server.close((error) => error ? reject(error) : resolve()));
});
}
async function waitForHealthyInstance(pid: number, platform: string, botId: string, config: Parameters<typeof localBaseUrl>[0]): Promise<void> {
let lastError = "health endpoint not ready";
for (let attempt = 0; attempt < 30; attempt++) {
if (!isPidRunning(pid)) throw new Error("child process exited before becoming healthy");
try {
const response = await fetch(`${localBaseUrl(config)}/health`, { signal: AbortSignal.timeout(500) });
const health = await response.json() as { botId?: string; platform?: string };
if (response.ok && health.botId === botId && health.platform === platform) return;
lastError = `health identity mismatch (HTTP ${response.status})`;
} catch (error) { lastError = error instanceof Error ? error.message : String(error); }
await delay(100);
}
throw new Error(`health check timed out: ${lastError}`);
}
function assertNoOverlappingWorkspace(workspace: string, botId: string): void {
const conflict = findOverlappingWorkspace(workspace, botId, instancesRoot());
if (conflict) throw new Error(`Refusing to start: bot '${conflict}' declares an identical, parent, or child workspace; workspace scopes must be disjoint`);
}
async function withInstanceLifecycleLock<T>(home: string, operation: () => Promise<T>): Promise<T> {
const lockFile = path.join(home, "state", "instance.lifecycle.lock");
const fd = fs.openSync(lockFile, "a", 0o600);
fs.closeSync(fd);
fs.chmodSync(lockFile, 0o600);
const holder = spawn(LIFECYCLE_FLOCK, ["-n", lockFile, process.execPath, "-e", "process.stdout.write('READY\\n'); process.stdin.resume()"], {
shell: false,
stdio: ["pipe", "pipe", "pipe"]
});
try {
await new Promise<void>((resolve, reject) => {
let output = "";
const timer = setTimeout(() => reject(new Error("Timed out acquiring instance lifecycle lock")), 2_000);
const finish = (error?: Error): void => {
clearTimeout(timer);
holder.stdout.removeAllListeners("data");
holder.removeAllListeners("error");
holder.removeAllListeners("close");
error ? reject(error) : resolve();
};
holder.stdout.on("data", (chunk: Buffer) => {
output += chunk.toString("utf8");
if (output.includes("READY\n")) finish();
});
holder.once("error", (error) => finish(error));
holder.once("close", () => finish(new Error("Another setup or start operation is already running for this instance")));
});
return await operation();
} finally {
holder.stdin.end();
if (holder.exitCode === null && holder.signalCode === null) holder.kill("SIGTERM");
await new Promise<void>((resolve) => {
if (holder.exitCode !== null || holder.signalCode !== null) resolve();
else holder.once("close", () => resolve());
});
}
}
function delay(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); }
async function terminateStartedChild(pid: number): Promise<void> {
if (!isPidRunning(pid)) return;
try { process.kill(pid, "SIGTERM"); } catch { return; }
for (let attempt = 0; attempt < 20 && isPidRunning(pid); attempt++) await delay(50);
if (isPidRunning(pid)) try { process.kill(pid, "SIGKILL"); } catch { /* process already exited */ }
}
function withInstanceHome<T>(home: string, fn: () => T): T {
const previous = process.env.GORI_AGENT_HOME;
process.env.GORI_AGENT_HOME = home;
try { return fn(); } finally { restoreInstanceHome(previous); }
}
async function withInstanceHomeAsync<T>(home: string, fn: () => Promise<T>): Promise<T> {
const previous = process.env.GORI_AGENT_HOME;
process.env.GORI_AGENT_HOME = home;
try { return await fn(); } finally { restoreInstanceHome(previous); }
}
function restoreInstanceHome(previous: string | undefined): void {
if (previous === undefined) delete process.env.GORI_AGENT_HOME;
else process.env.GORI_AGENT_HOME = previous;
}
+10 -17
View File
@@ -2,48 +2,41 @@ import os from "node:os";
import type { AppConfig } from "../config.js"; import type { AppConfig } from "../config.js";
export function localBaseUrl(config: AppConfig): string { 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[] { export function lanBaseUrls(config: AppConfig): string[] {
const urls: string[] = []; const urls: string[] = [];
for (const interfaces of Object.values(os.networkInterfaces())) { for (const interfaces of Object.values(os.networkInterfaces())) {
for (const item of interfaces || []) { for (const item of interfaces || []) {
if (item.family === "IPv4" && !item.internal) { if (item.family === "IPv4" && !item.internal) urls.push(`http://${item.address}:${config.gateway.server.port}`);
urls.push(`http://${item.address}:${config.server.port}`);
}
} }
} }
return urls; return urls;
} }
export async function publicBaseUrlHint(config: AppConfig): Promise<string | undefined> { export async function publicBaseUrlHint(config: AppConfig): Promise<string | undefined> {
if (config.server.publicBaseUrl) return config.server.publicBaseUrl.replace(/\/$/, ""); if (config.gateway.server.publicBaseUrl) return config.gateway.server.publicBaseUrl.replace(/\/$/, "");
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 1_500); const timer = setTimeout(() => controller.abort(), 1_500);
try { try {
const response = await fetch("https://api.ipify.org?format=text", { signal: controller.signal }); const response = await fetch("https://api.ipify.org?format=text", { signal: controller.signal });
if (!response.ok) return undefined; if (!response.ok) return undefined;
const ip = (await response.text()).trim(); const ip = (await response.text()).trim();
if (!ip) return undefined; return ip ? `http://${ip}:${config.gateway.server.port}` : undefined;
return `http://${ip}:${config.server.port}`; } catch { return undefined; } finally { clearTimeout(timer); }
} catch {
return undefined;
} finally {
clearTimeout(timer);
}
} }
export async function printUrlHints(config: AppConfig): Promise<void> { export async function printUrlHints(config: AppConfig): Promise<void> {
console.log(`Local: ${localBaseUrl(config)}`); console.log(`Local: ${localBaseUrl(config)}`);
const lanUrls = lanBaseUrls(config); const lanUrls = lanBaseUrls(config);
if (lanUrls.length > 0) console.log(`LAN: ${lanUrls.join(", ")}`); if (lanUrls.length > 0) console.log(`LAN: ${lanUrls.join(", ")}`);
const publicHint = await publicBaseUrlHint(config); if (config.gateway.server.publicBaseUrl) console.log(`Public: ${config.gateway.server.publicBaseUrl.replace(/\/$/, "")}`);
if (publicHint) console.log(`Public hint: ${publicHint}`); else console.log("Set gateway.server.publicBaseUrl when exposing through HTTPS/reverse proxy.");
if (!config.server.publicBaseUrl) console.log("Set server.publicBaseUrl when exposing through HTTPS/reverse proxy.");
} }
export async function webhookBaseUrl(config: AppConfig): Promise<string> { export async function webhookBaseUrl(config: AppConfig): Promise<string> {
return config.server.publicBaseUrl?.replace(/\/$/, "") || await publicBaseUrlHint(config) || localBaseUrl(config); return config.gateway.server.publicBaseUrl?.replace(/\/$/, "") || await publicBaseUrlHint(config) || localBaseUrl(config);
} }
-18
View File
@@ -1,18 +0,0 @@
import type { AppConfig } from "../config.js";
import { webhookBaseUrl } from "./net.js";
export async function printFeishu(config: AppConfig): Promise<void> {
const baseUrl = await webhookBaseUrl(config);
console.log("Feishu/Lark setup instructions");
console.log("");
console.log(`Webhook URL: ${baseUrl}/webhook/feishu`);
console.log("Event subscription: im.message.receive_v1");
console.log("Required config fields: platforms.feishu.appId, appSecret, verificationToken, botNames");
console.log("Do not paste appSecret into chats or logs.");
if (!config.platforms.feishu.enabled) {
console.log("Warning: platforms.feishu.enabled is false in this config.");
}
if (!config.server.publicBaseUrl) {
console.log("Warning: server.publicBaseUrl is empty; configure your public HTTPS URL before production use.");
}
}
+20 -1
View File
@@ -1,8 +1,10 @@
import readline from "node:readline/promises"; import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process"; import { stdin as input, stdout as output } from "node:process";
import { Writable } from "node:stream";
export interface PromptSession { export interface PromptSession {
ask(question: string, defaultValue?: string): Promise<string>; ask(question: string, defaultValue?: string): Promise<string>;
askSecret(question: string, hasExisting?: boolean): Promise<string>;
askBoolean(question: string, defaultValue?: boolean): Promise<boolean>; askBoolean(question: string, defaultValue?: boolean): Promise<boolean>;
askList(question: string, defaultValues?: string[]): Promise<string[]>; askList(question: string, defaultValues?: string[]): Promise<string[]>;
choose<T>(question: string, choices: Choice<T>[], defaultIndex?: number): Promise<T>; choose<T>(question: string, choices: Choice<T>[], defaultIndex?: number): Promise<T>;
@@ -16,9 +18,14 @@ export interface Choice<T> {
} }
export function createPromptSession(): PromptSession { export function createPromptSession(): PromptSession {
const rl = readline.createInterface({ input, output }); let rl = readline.createInterface({ input, output });
return { return {
ask: (question, defaultValue) => ask(rl, question, defaultValue), 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), askBoolean: (question, defaultValue) => askBoolean(rl, question, defaultValue),
askList: (question, defaultValues) => askList(rl, question, defaultValues), askList: (question, defaultValues) => askList(rl, question, defaultValues),
choose: (question, choices, defaultIndex) => choose(rl, question, choices, defaultIndex), choose: (question, choices, defaultIndex) => choose(rl, question, choices, defaultIndex),
@@ -26,6 +33,18 @@ export function createPromptSession(): PromptSession {
}; };
} }
async function askSecret(question: string, hasExisting = false): Promise<string> {
if (!input.isTTY || !output.isTTY) throw new Error("Secret input requires an interactive terminal");
const muted = new Writable({ write(_chunk, _encoding, callback) { callback(); } });
const secretRl = readline.createInterface({ input, output: muted, terminal: true });
output.write(`${question}${hasExisting ? " (leave blank to keep existing)" : ""}: `);
try {
const answer = (await secretRl.question("")).trim();
output.write("\n");
return answer;
} finally { secretRl.close(); }
}
export async function ask(rl: readline.Interface, question: string, defaultValue?: string): Promise<string> { export async function ask(rl: readline.Interface, question: string, defaultValue?: string): Promise<string> {
const suffix = defaultValue !== undefined && defaultValue !== "" ? ` [${defaultValue}]` : ""; const suffix = defaultValue !== undefined && defaultValue !== "" ? ` [${defaultValue}]` : "";
const answer = (await rl.question(`${question}${suffix}: `)).trim(); const answer = (await rl.question(`${question}${suffix}: `)).trim();
+7 -21
View File
@@ -1,26 +1,12 @@
import type { AppConfig } from "../config.js"; import type { FeishuConfig } from "../config.js";
import type { PromptSession } from "./prompt.js"; import type { PromptSession } from "./prompt.js";
export async function configureFeishu( export async function configureFeishu(prompt: PromptSession, existing: FeishuConfig): Promise<FeishuConfig> {
prompt: PromptSession,
existing: AppConfig["platforms"]["feishu"]
): Promise<AppConfig["platforms"]["feishu"]> {
console.log("\nFeishu/Lark setup");
console.log("Create a Feishu/Lark app, enable bot messaging, and subscribe to im.message.receive_v1.");
const appId = await prompt.ask("App ID", existing.appId && existing.appId !== "cli_xxx" ? existing.appId : undefined);
const appSecretAnswer = await prompt.ask(existing.appSecret ? "App Secret (leave blank to keep existing)" : "App Secret");
const verificationToken = await prompt.ask(
"Verification token",
existing.verificationToken && existing.verificationToken !== "replace-me" ? existing.verificationToken : undefined
);
const botNames = await prompt.askList("Bot display names, comma-separated", existing.botNames);
return { return {
enabled: true, type: "feishu",
appId, appId: await prompt.ask("App ID", existing.appId),
appSecret: appSecretAnswer || existing.appSecret, appSecret: await prompt.askSecret("App Secret", Boolean(existing.appSecret)) || existing.appSecret,
verificationToken, verificationToken: await prompt.askSecret("Verification token", Boolean(existing.verificationToken)) || existing.verificationToken,
botNames botNames: await prompt.askList("Bot display names, comma-separated", existing.botNames)
}; };
} }
+52
View File
@@ -0,0 +1,52 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { isValidServerHost, parseConfig } from "../config.js";
export function parseServerHost(input: string): string {
const host = input.trim();
if (!isValidServerHost(host)) throw new Error("server host must be a hostname, IPv4 address, or unbracketed IPv6 address without a port");
return host;
}
export function parseServerPort(input: string): number {
const value = input.trim();
if (!/^[0-9]+$/.test(value)) throw new Error("server port must be a decimal integer from 1 to 65535");
const port = Number(value);
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) throw new Error("server port must be a decimal integer from 1 to 65535");
return port;
}
export function suggestAvailablePort(preferred: number, usedPorts: ReadonlySet<number>): number {
for (let port = preferred; port <= 65_535; port++) {
if (!usedPorts.has(port)) return port;
}
throw new Error(`No unassigned instance port is available at or above ${preferred}`);
}
export function defaultInstancesDirectory(): string {
const root = path.resolve(process.env.GORI_AGENT_ROOT || path.join(os.homedir(), ".gori-agent"));
return path.join(root, "instances");
}
export function collectUsedInstancePorts(instancesDirectory: string, excludeConfigPath?: string): Set<number> {
const used = new Set<number>();
const excluded = excludeConfigPath ? path.resolve(excludeConfigPath) : undefined;
let entries: fs.Dirent[];
try { entries = fs.readdirSync(instancesDirectory, { withFileTypes: true }); }
catch { return used; }
for (const entry of entries) {
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
const configFile = path.join(instancesDirectory, entry.name, "config.json");
if (excluded && path.resolve(configFile) === excluded) continue;
try {
const stat = fs.lstatSync(configFile);
if (!stat.isFile() || stat.isSymbolicLink()) continue;
const config = parseConfig(JSON.parse(fs.readFileSync(configFile, "utf8")) as unknown);
used.add(config.gateway.server.port);
} catch { /* Ignore invalid or unreadable peer instances. */ }
}
return used;
}
+4 -11
View File
@@ -1,15 +1,8 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import type { AppConfig } from "../config.js"; import type { WebhookConfig } from "../config.js";
import type { PromptSession } from "./prompt.js"; import type { PromptSession } from "./prompt.js";
export async function configureGenericWebhook( export async function configureGenericWebhook(prompt: PromptSession, existing: WebhookConfig): Promise<WebhookConfig> {
prompt: PromptSession, const answer = await prompt.askSecret("Webhook secret (leave blank to generate)", Boolean(existing.secret));
existing: AppConfig["platforms"]["webhook"] return { type: "webhook", secret: answer || existing.secret || crypto.randomBytes(24).toString("hex") };
): Promise<AppConfig["platforms"]["webhook"]> {
console.log("\nGeneric webhook setup");
console.log("Use POST /webhook/generic with optional X-Gori-Signature HMAC-SHA256 authentication.");
const generated = existing.secret && existing.secret !== "replace-me" ? existing.secret : crypto.randomBytes(24).toString("hex");
const secret = await prompt.ask("Webhook secret", generated);
return { enabled: true, secret };
} }
+4 -11
View File
@@ -1,15 +1,8 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import type { AppConfig } from "../config.js"; import type { WeixinConfig } from "../config.js";
import type { PromptSession } from "./prompt.js"; import type { PromptSession } from "./prompt.js";
export async function configureWeixin( export async function configureWeixin(prompt: PromptSession, existing: WeixinConfig): Promise<WeixinConfig> {
prompt: PromptSession, const answer = await prompt.askSecret("Bridge secret (leave blank to generate)", Boolean(existing.secret));
existing: AppConfig["platforms"]["weixin"] return { type: "weixin", mode: "external-webhook", secret: answer || existing.secret || crypto.randomBytes(24).toString("hex") };
): Promise<AppConfig["platforms"]["weixin"]> {
console.log("\nPersonal WeChat external webhook setup");
console.log("Native personal WeChat integration is not included; use an external bridge that POSTs to /webhook/weixin.");
const generated = existing.secret && existing.secret !== "replace-me" ? existing.secret : crypto.randomBytes(24).toString("hex");
const secret = await prompt.ask("Bridge secret/reference", generated);
return { enabled: true, mode: "external-webhook", secret };
} }
+167 -54
View File
@@ -1,66 +1,179 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { discoverBackends } from "../acp/discovery.js"; import { discoverBackends } from "../acp/discovery.js";
import type { AppConfig } from "../config.js"; import type { AppConfig, PlatformConfig } from "../config.js";
import { createPromptSession } from "./prompt.js"; import { createPromptSession, type PromptSession } from "./prompt.js";
import { loadConfigFile, projectRoot, writeConfigFile } from "./config-file.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;
export async function runSetup(configPath?: string): Promise<void> { requireNew?: boolean;
const loaded = loadConfigFile(configPath); writeWithoutConfirmation?: boolean;
const prompt = createPromptSession(); prompt?: PromptSession;
try { discoverAgents?: typeof discoverBackends;
console.log("gori-agent ACP setup"); instancesDirectory?: string;
console.log(`Config target: ${loaded.path}`); log?: (message: string) => void;
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");
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());
} }
export async function runSetup(configPath?: string, options: SetupOptions = {}): Promise<boolean> {
const target = path.resolve(configPath || "config.json");
const configExists = fs.existsSync(target);
if (options.requireNew && configExists) throw new Error(`Config already exists: ${target}`);
const current = configExists ? loadConfigFile(target).config : loadExampleConfig();
if (configExists && options.botId && options.botId !== current.bot.id) {
throw new Error(`Cannot change existing bot.id '${current.bot.id}' to '${options.botId}'`);
}
const prompt = options.prompt || createPromptSession();
const discoverAgents = options.discoverAgents || discoverBackends;
const log = options.log || console.log;
try {
log("gori-agent Config v3 setup");
log(`Config target: ${target}`);
const discovered = await discoverAgents();
for (const agent of discovered) log(`- ${agent.id}: ${agent.status}${agent.version ? ` (${agent.version})` : ""}${agent.reason ? ` - ${agent.reason}` : ""}`);
const ready = discovered.filter((agent) => agent.status === "ready");
if (!configExists && ready.length === 0) throw new Error("No ACP agent is available");
const selected = configExists ? current.bot.agent : ready.find((agent) => agent.id === current.bot.agent.id) || ready[0];
const botId = configExists ? current.bot.id : options.botId || await prompt.ask("Bot ID");
const workspace = path.resolve(await prompt.ask("Bot workspace", current.bot.workspace.startsWith("/absolute/") ? projectRoot() : current.bot.workspace));
const persona = await prompt.ask("Bot persona", current.bot.persona);
const usedPorts = collectUsedInstancePorts(options.instancesDirectory || defaultInstancesDirectory(), target);
const server = await configureServer(prompt, current.gateway.server, usedPorts, !configExists, log);
const platformType = await prompt.choose("Platform", [
{ label: "QQ", value: "qq" as const },
{ label: "Feishu/Lark", value: "feishu" as const },
{ label: "WeCom", value: "wecom" as const },
{ label: "Generic webhook", value: "webhook" as const },
{ label: "Weixin bridge", value: "weixin" as const }
], ["qq", "feishu", "wecom", "webhook", "weixin"].indexOf(current.gateway.platform.type));
const platform = await configurePlatform(prompt, platformType, current.gateway.platform);
const isTemplate = current.bot.id === "BOT_ID";
const next: AppConfig = { const next: AppConfig = {
...loaded.config, configVersion: 3,
configVersion: 2, bot: {
backends: [{ id: "kimi", command: kimi.command, args: ["acp"], env: {} }], id: botId,
skills, workspace,
defaultRole: "assistant", persona,
roles, assistantPersona: isTemplate ? "" : current.bot.assistantPersona,
platforms: structuredClone(loaded.config.platforms) agent: configExists ? current.bot.agent : {
id: selected!.id,
command: selected!.command,
args: selected!.args,
env: {}
},
skills: isTemplate ? [] : current.bot.skills,
permissions: isTemplate ? { mode: "deny", allowedTools: [], allowedCommandPatterns: [] } : current.bot.permissions
},
gateway: { server, policy: current.gateway.policy, platform },
runtime: current.runtime
}; };
console.log("\nMigration summary:"); const shouldWrite = options.writeWithoutConfirmation || await prompt.askBoolean(`Write Config v3 to ${target}`, false);
console.log(`- default role: ${next.defaultRole}`); if (!shouldWrite) { log("No changes written."); return false; }
console.log(`- roles: ${next.roles.map(({ id }) => id).join(", ")}`); writeConfigFile(target, next);
console.log(`- backend: ${kimi.command} acp`); log(`Wrote ${target} with mode 0600`);
console.log("- all existing platform settings and credentials are preserved"); return true;
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.");
} finally { prompt.close(); } } finally { prompt.close(); }
} }
function opsRole(): AppConfig["roles"][number] { async function configureServer(
return { prompt: PromptSession,
id: "ops", backend: "kimi", workspace: "/home/ubuntu/gori-space", existing: AppConfig["gateway"]["server"],
persona: "你是 Gori 团队运维角色。严格遵循 gori-update skill;有风险或需要外部确认时停止并报告。", usedPorts: ReadonlySet<number>,
skills: ["gori-update"], isNew: boolean,
policy: { log: (message: string) => void
permissionMode: "allowlist", ): Promise<AppConfig["gateway"]["server"]> {
allowedTools: ["read", "grep", "glob", "bash"], let host: string;
allowedCommandPatterns: [ for (;;) {
"^(?:.*\\\"command\\\":\\\")?(?:git (?:status|log|diff|pull --ff-only)|bash gori-deploy/(?:build\\.sh|dist/deploy-[a-z-]+\\.sh)|docker (?:ps|logs))" try {
] host = parseServerHost(await prompt.ask("Gateway server host", existing.host));
break;
} catch (error) {
log(`ERROR: ${error instanceof Error ? error.message : String(error)}`);
} }
}
const preferredPort = existing.port;
let suggestedPort = preferredPort;
if (usedPorts.has(preferredPort)) {
try {
suggestedPort = suggestAvailablePort(preferredPort, usedPorts);
log(`WARNING: Gateway server port ${preferredPort} is already declared by another instance; suggested port: ${suggestedPort}.`);
} catch (error) {
if (isNew) throw error;
log(`WARNING: Gateway server port ${preferredPort} is already declared by another instance, and no higher unassigned port is available.`);
}
}
let port: number;
for (;;) {
try {
port = parseServerPort(await prompt.ask("Gateway server port", String(isNew ? suggestedPort : preferredPort)));
break;
} catch (error) {
log(`ERROR: ${error instanceof Error ? error.message : String(error)}`);
}
}
if (usedPorts.has(port)) {
log(`WARNING: Selected gateway server port ${port} is already declared by another instance; start will fail if that port is in use.`);
}
return { host, port, publicBaseUrl: existing.publicBaseUrl };
}
async function configurePlatform(
prompt: PromptSession,
type: PlatformConfig["type"],
existing: PlatformConfig
): Promise<PlatformConfig> {
switch (type) {
case "qq": {
const previous = existing.type === "qq" ? existing : undefined;
return {
type,
connectionMode: await prompt.choose("QQ connection mode", [
{ label: "WebSocket", value: "websocket" as const },
{ label: "Webhook", value: "webhook" as const }
], previous?.connectionMode === "webhook" ? 1 : 0),
appId: await prompt.ask("QQ App ID", previous?.appId),
clientSecret: await prompt.askSecret("QQ Client Secret", Boolean(previous?.clientSecret)) || previous?.clientSecret || "",
botSecret: await prompt.askSecret("QQ Bot Secret", Boolean(previous?.botSecret)) || previous?.botSecret || "",
verifySignature: previous?.verifySignature ?? true,
botNames: await prompt.askList("QQ Bot names", previous?.botNames),
intents: previous?.intents ?? 33_554_432,
shard: previous?.shard ?? [0, 1]
}; };
} }
case "feishu": {
const previous = existing.type === "feishu" ? existing : undefined;
return {
type,
appId: await prompt.ask("Feishu App ID", previous?.appId),
appSecret: await prompt.askSecret("Feishu App Secret", Boolean(previous?.appSecret)) || previous?.appSecret || "",
verificationToken: await prompt.askSecret("Feishu verification token", Boolean(previous?.verificationToken)) || previous?.verificationToken || "",
botNames: await prompt.askList("Feishu Bot names", previous?.botNames)
};
}
case "wecom": {
const previous = existing.type === "wecom" ? existing : undefined;
return {
type,
corpId: await prompt.ask("WeCom Corp ID", previous?.corpId),
agentId: await prompt.ask("WeCom Agent ID", previous?.agentId),
secret: await prompt.askSecret("WeCom Secret", Boolean(previous?.secret)) || previous?.secret || ""
};
}
case "webhook": {
const previous = existing.type === "webhook" ? existing : undefined;
const secret = await prompt.askSecret("Webhook secret", Boolean(previous?.secret));
return { type, secret: secret || previous?.secret || crypto.randomBytes(24).toString("hex") };
}
case "weixin": {
const previous = existing.type === "weixin" ? existing : undefined;
const secret = await prompt.askSecret("Weixin bridge secret", Boolean(previous?.secret));
return { type, mode: "external-webhook", secret: secret || previous?.secret || crypto.randomBytes(24).toString("hex") };
}
}
}
+110 -106
View File
@@ -1,95 +1,123 @@
import fs from "node:fs"; import fs from "node:fs";
import net from "node:net";
import path from "node:path"; import path from "node:path";
import process from "node:process"; import process from "node:process";
import { z } from "zod"; import { z } from "zod";
const policySchema = z.object({ const gatewayPolicySchema = z.object({
allowedUsers: z.array(z.string()).default([]), allowedUsers: z.array(z.string()).default([]),
allowedChats: 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({ const permissionPolicySchema = z.object({
permissionMode: z.enum(["deny", "allowlist", "auto"]).default("deny"), mode: z.enum(["deny", "allowlist", "auto"]).default("deny"),
allowedTools: z.array(z.string()).default([]), allowedTools: z.array(z.string()).default([]),
allowedCommandPatterns: 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), id: z.string().min(1),
command: z.string().min(1), command: z.string().min(1),
args: z.array(z.string()).default([]), args: z.array(z.string()).default([]),
env: z.record(z.string()).default({}) env: z.record(z.string()).default({})
}); }).strict();
const skillSchema = z.object({ const skillSchema = z.object({
id: z.string().min(1), id: z.string().min(1),
file: z.string().min(1), file: z.string().min(1),
maxBytes: z.number().int().positive().default(256_000) maxBytes: z.number().int().positive().default(256_000)
}); }).strict();
const roleSchema = z.object({ const botIdSchema = z.union([
id: z.string().min(1), z.literal("BOT_ID"),
backend: z.string().min(1), 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), workspace: z.string().min(1),
persona: z.string().default(""), persona: z.string().default(""),
skills: z.array(z.string()).default([]), assistantPersona: 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({ const acpSchema = z.object({
stateFile: z.string().default(""), stateFile: z.string().default(""),
initializeTimeoutMs: z.number().int().positive().default(10_000), initializeTimeoutMs: z.number().int().positive().default(10_000),
promptTimeoutMs: z.number().int().positive().default(600_000), promptTimeoutMs: z.number().int().positive().default(14_400_000),
cancelGraceMs: z.number().int().positive().default(5_000), cancelGraceMs: z.number().int().positive().default(5_000),
idleTimeoutMs: z.number().int().positive().default(1_800_000), idleTimeoutMs: z.number().int().positive().default(1_800_000),
assistantSessionResetIdleMs: z.number().int().nonnegative().default(3_600_000),
sweepIntervalMs: z.number().int().positive().default(60_000), sweepIntervalMs: z.number().int().positive().default(60_000),
maxProcesses: z.number().int().positive().default(8) maxProcesses: z.number().int().positive().default(8),
}); maxAssistantSessions: z.number().int().positive().default(4)
}).strict();
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("")
});
export const configSchema = z.object({ export const configSchema = z.object({
configVersion: z.literal(2), configVersion: z.literal(3),
server: z.object({ bot: botSchema,
host: z.string().default("0.0.0.0"), port: z.number().int().positive().max(65_535).default(3000), publicBaseUrl: z.string().default("") gateway: z.object({
}).default({}), server: serverSchema.default({}),
policy: policySchema.default({}), policy: gatewayPolicySchema.default({}),
acp: acpSchema.default({}), platform: platformSchema
backends: z.array(backendSchema).min(1), }).strict(),
skills: z.array(skillSchema).default([]), runtime: z.object({ acp: acpSchema.default({}) }).strict().default({})
defaultRole: z.string().min(1), }).strict();
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({})
});
export type AppConfig = z.infer<typeof configSchema>; export type AppConfig = z.infer<typeof configSchema>;
export type GatewayPolicy = z.infer<typeof policySchema>; export type BotConfig = AppConfig["bot"];
export type AcpConfig = AppConfig["acp"]; export type AgentConfig = BotConfig["agent"];
export type AcpBackendConfig = AppConfig["backends"][number]; export type PermissionPolicy = BotConfig["permissions"];
export type RoleConfig = AppConfig["roles"][number]; export type SkillConfig = BotConfig["skills"][number];
export type RolePolicy = RoleConfig["policy"]; export type GatewayPolicy = AppConfig["gateway"]["policy"];
export type SkillConfig = AppConfig["skills"][number]; export type AcpConfig = AppConfig["runtime"]["acp"];
export type PlatformConfig = AppConfig["gateway"]["platform"];
export type PlatformType = PlatformConfig["type"];
export type QqConfig = Extract<PlatformConfig, { type: "qq" }>;
export type FeishuConfig = Extract<PlatformConfig, { type: "feishu" }>;
export type WeComConfig = Extract<PlatformConfig, { type: "wecom" }>;
export type WebhookConfig = Extract<PlatformConfig, { type: "webhook" }>;
export type WeixinConfig = Extract<PlatformConfig, { type: "weixin" }>;
// Kept only so legacy CliAgent source remains type-checkable; it is not used by the runtime. // Kept only so legacy CliAgent source remains type-checkable; it is not used by the runtime.
export interface CliAgentConfig { export interface CliAgentConfig {
@@ -102,68 +130,37 @@ export interface CliAgentConfig {
cwd?: string; cwd?: string;
} }
interface V1AgentConfig {
name?: string;
command?: string;
args?: string[];
cwd?: string;
}
export function resolveConfigPath(configPath = process.env.GORI_GATEWAY_CONFIG): 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 { export function parseConfig(rawConfig: unknown): AppConfig {
const candidate = isRecord(rawConfig) && rawConfig.configVersion === 2 ? rawConfig : migrateV1Config(rawConfig); if (!isRecord(rawConfig)) throw new Error("configuration must be an object");
const config = configSchema.parse(candidate); if (rawConfig.configVersion !== 3) {
validateUnique(config.backends.map((item) => item.id), "backend"); throw new Error(`unsupported configVersion '${String(rawConfig.configVersion)}'; gori-agent requires Config v3`);
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}'`); }
} }
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; 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 { 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(); const home = process.env.GORI_AGENT_HOME || process.cwd();
return path.join(path.resolve(home), "state", "acp-sessions.json"); return path.join(path.resolve(home), "state", "acp-sessions.json");
} }
export function defaultProposalFile(config: AppConfig): string {
return path.join(path.dirname(defaultStateFile(config)), "proposals.json");
}
export function loadConfig(configPath = process.env.GORI_GATEWAY_CONFIG): AppConfig { export function loadConfig(configPath = process.env.GORI_GATEWAY_CONFIG): AppConfig {
return loadConfigFromPath(resolveConfigPath(configPath)); return loadConfigFromPath(resolveConfigPath(configPath));
} }
@@ -172,6 +169,13 @@ export function loadConfigFromPath(configPath: string): AppConfig {
return parseConfig(JSON.parse(fs.readFileSync(configPath, "utf8")) as unknown); 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 { function validateUnique(ids: string[], label: string): void {
if (new Set(ids).size !== ids.length) throw new Error(`${label} IDs must be unique`); if (new Set(ids).size !== ids.length) throw new Error(`${label} IDs must be unique`);
} }
+3
View File
@@ -2,6 +2,9 @@ import type { OutgoingMessage, WebhookRequestContext, WebhookResponse } from "./
export interface PlatformAdapter { export interface PlatformAdapter {
name: string; name: string;
// True when the adapter can deliver OutgoingMessage.images (e.g. QQ msg_type 7);
// gateways degrade images to text notes for adapters without this flag.
supportsImages?: boolean;
handleWebhook(context: WebhookRequestContext): Promise<WebhookResponse>; handleWebhook(context: WebhookRequestContext): Promise<WebhookResponse>;
sendMessage(message: OutgoingMessage): Promise<void>; sendMessage(message: OutgoingMessage): Promise<void>;
} }
+7 -9
View File
@@ -1,25 +1,23 @@
export type CommandKind = "help" | "roles" | "role" | "status" | "cancel" | "new"; export type CommandKind = "help" | "status" | "list" | "confirm" | "finish" | "stop" | "cancel" | "reset";
export interface ParsedCommand { export interface ParsedCommand {
kind: CommandKind; kind: CommandKind;
argument?: string;
deprecatedAlias?: boolean;
} }
export class CommandRouter { export class CommandRouter {
parse(text: string): ParsedCommand | undefined { parse(text: string): ParsedCommand | undefined {
const trimmed = text.trim(); const trimmed = text.trim();
if (!trimmed.startsWith("/")) return undefined; if (!trimmed.startsWith("/")) return undefined;
const [command, ...args] = trimmed.split(/\s+/); const [command] = trimmed.split(/\s+/);
switch (command.toLowerCase()) { switch (command.toLowerCase()) {
case "/help": return { kind: "help" }; case "/help": return { kind: "help" };
case "/roles": return { kind: "roles" };
case "/role": return { kind: "role", argument: args[0] };
case "/status": return { kind: "status" }; case "/status": return { kind: "status" };
case "/list": return { kind: "list" };
case "/confirm": return { kind: "confirm" };
case "/finish": return { kind: "finish" };
case "/stop": return { kind: "stop" };
case "/cancel": return { kind: "cancel" }; case "/cancel": return { kind: "cancel" };
case "/new": return { kind: "new" }; case "/reset": return { kind: "reset" };
case "/agents": return { kind: "roles", deprecatedAlias: true };
case "/agent": return { kind: "role", argument: args[0], deprecatedAlias: true };
default: return undefined; default: return undefined;
} }
} }
+111 -53
View File
@@ -1,53 +1,74 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
export interface ChatState { export interface StoreIdentity {
selectedRole: string; botId: string;
createdAt: number; platform: string;
updatedAt: number;
} }
export interface SessionBinding { export interface AssistantBinding {
chatKey: string; chatKey: string;
roleId: string; agentId: string;
backendId: string;
nativeSessionId: string; nativeSessionId: string;
workspace: string; assistantWorkspace: string;
roleFingerprint: string; botFingerprint: string;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
// Refreshed on every assistant turn; drives the idle reset of long-inactive sessions.
lastActiveAt?: number;
} }
interface StoreData { interface StoreData {
version: 1; version: 3;
chats: Record<string, ChatState>; botId: string;
bindings: Record<string, SessionBinding>; platform: string;
bindings: Record<string, AssistantBinding>;
} }
const EMPTY: StoreData = { version: 1, chats: {}, bindings: {} };
export class DurableSessionStore { export class DurableSessionStore {
private data: StoreData = structuredClone(EMPTY); private data: StoreData;
private queue: Promise<void> = Promise.resolve(); private queue: Promise<void> = Promise.resolve();
private lockFd?: fs.promises.FileHandle; private lockFd?: fs.promises.FileHandle;
private closed = false; private closed = false;
constructor(readonly file: string) {} constructor(readonly file: string, readonly identity: StoreIdentity) {
this.data = emptyData(identity);
}
async open(): Promise<void> { async open(): Promise<void> {
await fs.promises.mkdir(path.dirname(this.file), { recursive: true }); const directory = path.dirname(this.file);
await fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
const directoryStat = await fs.promises.lstat(directory);
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) throw new Error(`Refusing unsafe state directory: ${directory}`);
const directoryMode = directoryStat.mode & 0o777;
if (directoryMode !== 0o700) throw new Error(`State directory mode must be 0700, got 0${directoryMode.toString(8)}`);
if (typeof process.getuid === "function" && directoryStat.uid !== process.getuid()) throw new Error("State directory is not owned by the current user");
const lockFile = `${this.file}.lock`; const lockFile = `${this.file}.lock`;
try { try {
this.lockFd = await acquireLock(lockFile); this.lockFd = await acquireLock(lockFile);
await this.lockFd.writeFile(`${process.pid}\n`);
} catch (error) { } catch (error) {
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}`); if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Session state is locked by another gori-agent instance: ${lockFile}`);
throw error; 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 { try {
const raw = await fs.promises.readFile(this.file, "utf8"); const raw = await fs.promises.readFile(this.file, "utf8");
const parsed = JSON.parse(raw) as StoreData; const parsed = parseStoreData(JSON.parse(raw) as unknown);
if (parsed.version !== 1 || !parsed.chats || !parsed.bindings) throw new Error("unsupported or malformed state data"); 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; this.data = parsed;
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") { if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
@@ -57,44 +78,33 @@ export class DurableSessionStore {
} }
} }
getSelectedRole(chatKey: string, defaultRole: string): string { getBinding(chatKey: string): AssistantBinding | undefined {
return this.data.chats[chatKey]?.selectedRole || defaultRole; const value = this.data.bindings[chatKey];
}
async setSelectedRole(chatKey: string, roleId: string): Promise<void> {
const now = Date.now();
const current = this.data.chats[chatKey];
this.data.chats[chatKey] = { selectedRole: roleId, createdAt: current?.createdAt || now, updatedAt: now };
await this.persist();
}
getBinding(chatKey: string, roleId: string): SessionBinding | undefined {
const value = this.data.bindings[bindingKey(chatKey, roleId)];
return value ? structuredClone(value) : undefined; return value ? structuredClone(value) : undefined;
} }
async setBinding(binding: SessionBinding): Promise<void> { async setBinding(binding: AssistantBinding): Promise<void> {
this.data.bindings[bindingKey(binding.chatKey, binding.roleId)] = structuredClone(binding); this.data.bindings[binding.chatKey] = structuredClone(binding);
await this.persist(); await this.persist();
} }
async touchBinding(chatKey: string, roleId: string): Promise<void> { async touchBinding(chatKey: string): Promise<void> {
const binding = this.data.bindings[bindingKey(chatKey, roleId)]; const binding = this.data.bindings[chatKey];
if (!binding) return; if (!binding) return;
binding.updatedAt = Date.now(); binding.updatedAt = Date.now();
binding.lastActiveAt = binding.updatedAt;
await this.persist(); await this.persist();
} }
async deleteBinding(chatKey: string, roleId: string): Promise<SessionBinding | undefined> { async deleteBinding(chatKey: string): Promise<AssistantBinding | undefined> {
const key = bindingKey(chatKey, roleId); const existing = this.data.bindings[chatKey];
const existing = this.data.bindings[key]; delete this.data.bindings[chatKey];
delete this.data.bindings[key];
if (existing) await this.persist(); if (existing) await this.persist();
return existing; return existing ? structuredClone(existing) : undefined;
} }
stats(): { chats: number; bindings: number } { stats(): { bindings: number } {
return { chats: Object.keys(this.data.chats).length, bindings: Object.keys(this.data.bindings).length }; return { bindings: Object.keys(this.data.bindings).length };
} }
async flush(): Promise<void> { await this.queue; } async flush(): Promise<void> { await this.queue; }
@@ -102,15 +112,15 @@ export class DurableSessionStore {
async close(): Promise<void> { async close(): Promise<void> {
if (this.closed) return; if (this.closed) return;
this.closed = true; this.closed = true;
await this.flush(); try { await this.flush(); } finally { await this.releaseLock(); }
await this.releaseLock();
} }
private persist(): Promise<void> { private persist(): Promise<void> {
if (this.closed) return Promise.reject(new Error("Session store is closed")); if (this.closed) return Promise.reject(new Error("Session store is closed"));
const snapshot = JSON.stringify(this.data, null, 2) + "\n"; const snapshot = JSON.stringify(this.data, null, 2) + "\n";
this.queue = this.queue.then(() => atomicWrite(this.file, snapshot)); const operation = this.queue.then(() => atomicWrite(this.file, snapshot));
return this.queue; this.queue = operation.catch(() => undefined);
return operation;
} }
private async releaseLock(): Promise<void> { private async releaseLock(): Promise<void> {
@@ -124,26 +134,74 @@ export class DurableSessionStore {
} }
export function chatKeyFor(platform: string, chatId: string): string { return `${platform}:${chatId}`; } 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: 3, botId: identity.botId, platform: identity.platform, bindings: {} };
}
function parseStoreData(value: unknown): StoreData {
if (!isRecord(value) || value.version !== 3 || typeof value.botId !== "string" || typeof value.platform !== "string" || !isRecord(value.bindings)) {
throw new Error("unsupported state version; Config v3 requires state v3 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.assistantWorkspace !== "string"
|| typeof binding.botFingerprint !== "string"
|| typeof binding.createdAt !== "number"
|| typeof binding.updatedAt !== "number"
|| (binding.lastActiveAt !== undefined && typeof binding.lastActiveAt !== "number")) {
throw new Error(`invalid state v3 binding '${chatKey}'`);
}
}
return value as unknown as StoreData;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function acquireLock(lockFile: string): Promise<fs.promises.FileHandle> { async function acquireLock(lockFile: string): Promise<fs.promises.FileHandle> {
return fs.promises.open(lockFile, "wx", 0o600); return fs.promises.open(lockFile, "wx", 0o600);
} }
async function removeStaleLock(lockFile: string): Promise<boolean> {
let handle: fs.promises.FileHandle | undefined;
try {
handle = await fs.promises.open(lockFile, "r");
const pid = Number((await handle.readFile("utf8")).trim());
if (!Number.isSafeInteger(pid) || pid <= 1 || processExists(pid)) return false;
const opened = await handle.stat();
const current = await fs.promises.lstat(lockFile);
if (opened.dev !== current.dev || opened.ino !== current.ino) return false;
await fs.promises.unlink(lockFile);
return true;
} catch { return false; }
finally { await handle?.close().catch(() => undefined); }
}
function processExists(pid: number): boolean {
try { process.kill(pid, 0); return true; }
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
}
async function atomicWrite(file: string, content: string): Promise<void> { async function atomicWrite(file: string, content: string): Promise<void> {
const temp = `${file}.${process.pid}.${Date.now()}.tmp`; const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
const handle = await fs.promises.open(temp, "wx", 0o600); let handle: fs.promises.FileHandle | undefined;
try { try {
handle = await fs.promises.open(temp, "wx", 0o600);
await handle.writeFile(content, "utf8"); await handle.writeFile(content, "utf8");
await handle.sync(); await handle.sync();
} finally {
await handle.close(); await handle.close();
} handle = undefined;
try {
await fs.promises.rename(temp, file); await fs.promises.rename(temp, file);
await fs.promises.chmod(file, 0o600);
const dir = await fs.promises.open(path.dirname(file), "r"); const dir = await fs.promises.open(path.dirname(file), "r");
try { await dir.sync(); } finally { await dir.close(); } try { await dir.sync(); } finally { await dir.close(); }
} catch (error) { } catch (error) {
if (handle) await handle.close().catch(() => undefined);
await fs.promises.unlink(temp).catch(() => undefined); await fs.promises.unlink(temp).catch(() => undefined);
throw error; throw error;
} }
+390 -54
View File
@@ -1,99 +1,435 @@
import type { ConversationRuntime } from "../acp/types.js"; import type { ConversationRuntime } from "../acp/types.js";
import type { GatewayPolicy } from "../config.js"; import type { GatewayPolicy } from "../config.js";
import type { RoleRegistry } from "../roles/role-registry.js";
import type { PlatformAdapter } from "./adapter.js"; import type { PlatformAdapter } from "./adapter.js";
import { CommandRouter, type ParsedCommand } from "./command-router.js"; import { CommandRouter, type ParsedCommand } from "./command-router.js";
import { chatKeyFor } from "./durable-session-store.js"; import { chatKeyFor } from "./durable-session-store.js";
import type { IncomingMessage } from "./types.js"; import type { Proposal } from "./proposal-store.js";
import type { IncomingMessage, MessageTarget, OutgoingImage, OutgoingImageRef } from "./types.js";
import { readOutboundImage } from "./workspace-images.js";
export interface GatewayResult { ok: boolean; reply?: string; ignored?: boolean; error?: string } export interface GatewayResult { ok: boolean; reply?: string; ignored?: boolean; error?: string }
interface ChatTarget {
target: MessageTarget;
adapter: PlatformAdapter;
lastInboundMessageId?: string;
lastInboundAt?: number;
inboundIsGroup: boolean;
lastEventDelivery?: "success" | "failed" | "skipped";
lastEventError?: string;
lastEventAt?: number;
}
interface PendingEvent {
text: string;
images?: OutgoingImageRef[];
}
export interface GatewayOptions {
now?: () => number;
}
// QQ passive-reply windows (with safety margin): 5 minutes for group chats, 60 minutes for C2C.
const GROUP_PASSIVE_WINDOW_MS = 4.5 * 60_000;
const C2C_PASSIVE_WINDOW_MS = 55 * 60_000;
// Reads image files at send time (the worker may have updated them); unreadable files degrade
// to a text note, and adapters without image support get a plain [图片] text line instead.
async function resolveOutboundImages(text: string, refs: OutgoingImageRef[] | undefined, imageCapable: boolean): Promise<{ text: string; images: OutgoingImage[] }> {
if (!refs || refs.length === 0) return { text, images: [] };
const images: OutgoingImage[] = [];
const failures: string[] = [];
for (const ref of refs) {
const image = await readOutboundImage(ref);
if (image) images.push(image);
else failures.push(ref.filename || "image");
}
let finalText = text;
if (failures.length > 0) {
finalText = [finalText, ...failures.map((name) => `(图片 ${name} 发送失败:文件不存在或已失效)`)].filter((part) => part.length > 0).join("\n");
}
if (!imageCapable && images.length > 0) {
finalText = [finalText, ...images.map((image) => `[图片] ${image.filename || "image"}`)].filter((part) => part.length > 0).join("\n");
return { text: finalText, images: [] };
}
return { text: finalText, images };
}
class ReplyStream {
private tail = Promise.resolve();
constructor(
private readonly message: IncomingMessage,
private readonly adapter: PlatformAdapter,
private readonly nextSequence: () => number | undefined
) {}
enqueue(entry: string | PendingEvent, onError?: (error: unknown) => void): Promise<void> {
const request = typeof entry === "string" ? { text: entry } : entry;
this.tail = this.tail.then(() => this.deliver(request)).catch((error: unknown) => {
console.error(`Gateway delivery send failed (platform=${this.message.platform})`);
onError?.(error);
});
return this.tail;
}
private async deliver(request: PendingEvent): Promise<void> {
const { text, images } = await resolveOutboundImages(request.text, request.images, this.adapter.supportsImages === true);
if (text || images.length === 0) await this.send({ text });
for (const image of images) {
try {
await this.send({ text: "", images: [image] });
} catch (error) {
console.error(`Gateway image delivery failed (platform=${this.message.platform})`);
await this.send({ text: `(图片 ${image.filename || "image"} 发送失败)` }).catch(() => undefined);
}
}
}
private send(piece: { text: string; images?: OutgoingImage[] }): Promise<void> {
return this.adapter.sendMessage({
target: {
platform: this.message.platform,
chatId: this.message.chatId,
userId: this.message.userId,
raw: this.message.raw
},
text: piece.text,
replyTo: this.message.messageId,
replySequence: this.nextSequence(),
images: piece.images
});
}
}
export class Gateway { export class Gateway {
private readonly locks = new Map<string, Promise<void>>(); private readonly chatTargets = new Map<string, ChatTarget>();
private readonly pendingEvents = new Map<string, PendingEvent[]>();
// QQ deduplicates passive replies by msg_id + msg_seq: all sends referencing the same inbound
// message (normal replies, events, redelivered pending events) share one counter per chat+messageId.
private readonly messageSequences = new Map<string, Map<string, number>>();
private readonly now: () => number;
private closed = false;
readonly commandRouter = new CommandRouter(); 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,
options: GatewayOptions = {}
) {
this.now = options.now || Date.now;
}
async receive(message: IncomingMessage, adapter: PlatformAdapter, options: { synchronous?: boolean } = {}): Promise<GatewayResult> { async receive(message: IncomingMessage, adapter: PlatformAdapter, options: { synchronous?: boolean } = {}): Promise<GatewayResult> {
const policyError = this.checkPolicy(message); const policyError = this.checkPolicy(message);
if (policyError) { 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 }; return { ok: true, ignored: true, error: policyError };
} }
const command = this.commandRouter.parse(message.text);
if (command?.kind === "cancel") return this.reply(message, adapter, await this.cancelText(message), options);
if (command?.kind === "new") await this.runtime.cancel(message.platform, message.chatId);
const chatKey = chatKeyFor(message.platform, message.chatId); const chatKey = chatKeyFor(message.platform, message.chatId);
return this.withChatLock(chatKey, async () => { const previous = this.chatTargets.get(chatKey);
this.chatTargets.set(chatKey, {
target: {
platform: message.platform,
chatId: message.chatId,
userId: message.userId,
raw: message.raw
},
adapter,
lastInboundMessageId: message.messageId,
lastInboundAt: this.now(),
inboundIsGroup: Boolean(message.isGroup),
lastEventDelivery: previous?.lastEventDelivery,
lastEventError: previous?.lastEventError,
lastEventAt: previous?.lastEventAt
});
await this.flushPendingEvents(chatKey, message, adapter, options);
const command = this.commandRouter.parse(message.text);
if (command) {
const result = await this.executeCommandResult(command, message);
if (!options.synchronous) await this.replyStream(chatKey, message, adapter).enqueue(result.reply || "");
return result;
}
if (options.synchronous) return this.promptResult(message);
try { try {
const reply = command ? await this.executeCommand(command, message) : (await this.runtime.prompt({ const response = await this.runtime.prompt({
platform: message.platform, chatId: message.chatId, userId: message.userId, text: message.text, messageId: message.messageId platform: message.platform,
})).text; chatId: message.chatId,
return this.reply(message, adapter, reply, options); userId: message.userId,
text: message.text,
messageId: message.messageId,
attachments: message.attachments
});
await this.replyStream(chatKey, message, adapter).enqueue({ text: response.text, images: response.images });
return { ok: true, reply: response.text };
} catch (error) { } catch (error) {
const errorText = error instanceof Error ? error.message : String(error); const errorText = error instanceof Error ? error.message : String(error);
const reply = `Agent error: ${errorText}`; const reply = `Agent error: ${errorText}`;
if (!options.synchronous) await this.send(message, adapter, reply); await this.replyStream(chatKey, message, adapter).enqueue(reply);
return { ok: false, error: errorText, reply }; return { ok: false, error: errorText, reply };
} }
}
async sendEvent(chatKey: string, text: string, images?: OutgoingImageRef[]): Promise<void> {
if (this.closed) return;
const entry = this.chatTargets.get(chatKey);
if (!entry) return;
const windowMs = entry.inboundIsGroup ? GROUP_PASSIVE_WINDOW_MS : C2C_PASSIVE_WINDOW_MS;
const freshInbound = entry.lastInboundMessageId
&& entry.lastInboundAt !== undefined
&& this.now() - entry.lastInboundAt <= windowMs;
if (!freshInbound) {
// No fresh passive window and proactive messages may be unauthorized: skip and remind on the next inbound.
this.recordEventDelivery(chatKey, "skipped", "no fresh passive window");
this.queuePendingEvent(chatKey, { text, images });
console.log(`Gateway event skipped: no fresh passive window (platform=${entry.target.platform})`);
return;
}
try {
const resolved = await resolveOutboundImages(text, images, entry.adapter.supportsImages === true);
if (resolved.text || resolved.images.length === 0) {
await entry.adapter.sendMessage({
target: entry.target,
text: resolved.text,
replyTo: entry.lastInboundMessageId,
replySequence: this.nextReplySequence(chatKey, entry.lastInboundMessageId)
}); });
} }
for (const image of resolved.images) {
try {
await entry.adapter.sendMessage({
target: entry.target,
text: "",
replyTo: entry.lastInboundMessageId,
replySequence: this.nextReplySequence(chatKey, entry.lastInboundMessageId),
images: [image]
});
} catch (imageError) {
// Image upload/send failures never block the event text; degrade to a text note.
console.error(`Gateway event image send failed (platform=${entry.target.platform})`);
await entry.adapter.sendMessage({
target: entry.target,
text: `(图片 ${image.filename || "image"} 发送失败)`,
replyTo: entry.lastInboundMessageId,
replySequence: this.nextReplySequence(chatKey, entry.lastInboundMessageId)
}).catch(() => undefined);
}
}
this.recordEventDelivery(chatKey, "success");
} catch (error) {
this.recordEventDelivery(chatKey, "failed", safeEventError(error));
this.queuePendingEvent(chatKey, { text, images });
console.error(`Gateway event send failed (platform=${entry.target.platform})`);
}
}
stats(): ReturnType<ConversationRuntime["stats"]> & { lockedChats: number } { return { ...this.runtime.stats(), lockedChats: this.locks.size }; } private recordEventDelivery(chatKey: string, delivery: "success" | "failed" | "skipped", error?: string): void {
const entry = this.chatTargets.get(chatKey);
if (!entry) return;
entry.lastEventDelivery = delivery;
entry.lastEventError = error;
entry.lastEventAt = this.now();
}
private queuePendingEvent(chatKey: string, event: PendingEvent): void {
const pending = this.pendingEvents.get(chatKey) || [];
pending.push(event);
this.pendingEvents.set(chatKey, pending);
}
private async flushPendingEvents(chatKey: string, message: IncomingMessage, adapter: PlatformAdapter, options: { synchronous?: boolean }): Promise<void> {
if (options.synchronous) return;
const pending = this.pendingEvents.get(chatKey);
if (!pending || pending.length === 0) return;
this.pendingEvents.delete(chatKey);
const stream = this.replyStream(chatKey, message, adapter);
for (const event of pending) {
// Images are re-read from disk at redelivery time (the worker may have updated them).
await stream.enqueue(event, (error) => {
// Redelivery failed: keep the event queued for the next inbound and record the failure.
this.recordEventDelivery(chatKey, "failed", safeEventError(error));
this.queuePendingEvent(chatKey, event);
});
}
}
private replyStream(chatKey: string, message: IncomingMessage, adapter: PlatformAdapter): ReplyStream {
return new ReplyStream(message, adapter, () => this.nextReplySequence(chatKey, message.messageId));
}
private nextReplySequence(chatKey: string, messageId: string | undefined): number | undefined {
if (!messageId) return undefined;
let sequences = this.messageSequences.get(chatKey);
if (!sequences) {
sequences = new Map();
this.messageSequences.set(chatKey, sequences);
}
const next = (sequences.get(messageId) || 0) + 1;
// Re-set to refresh insertion order, then bound memory to the most recent message ids per chat.
sequences.delete(messageId);
sequences.set(messageId, next);
while (sequences.size > 8) sequences.delete(sequences.keys().next().value!);
return next;
}
private eventStatus(chatKey: string): Record<string, string> {
const entry = this.chatTargets.get(chatKey);
return {
lastEventDelivery: entry?.lastEventDelivery || "none",
lastEventError: entry?.lastEventError || "none",
lastEventAt: entry?.lastEventAt ? new Date(entry.lastEventAt).toISOString() : "never"
};
}
stats(): ReturnType<ConversationRuntime["stats"]> {
return this.runtime.stats();
}
shutdown(): void {
this.closed = true;
}
private async executeCommandResult(command: ParsedCommand, message: IncomingMessage): Promise<GatewayResult> {
try {
return { ok: true, reply: await this.executeCommand(command, message) };
} catch (error) {
const errorText = error instanceof Error ? error.message : String(error);
return { ok: false, error: errorText, reply: `Agent error: ${errorText}` };
}
}
private async promptResult(message: IncomingMessage): Promise<GatewayResult> {
try {
const reply = (await this.runtime.prompt({
platform: message.platform,
chatId: message.chatId,
userId: message.userId,
text: message.text,
messageId: message.messageId,
attachments: message.attachments
})).text;
return { ok: true, reply };
} catch (error) {
const errorText = error instanceof Error ? error.message : String(error);
return { ok: false, error: errorText, reply: `Agent error: ${errorText}` };
}
}
private async executeCommand(command: ParsedCommand, message: IncomingMessage): Promise<string> { private async executeCommand(command: ParsedCommand, message: IncomingMessage): Promise<string> {
const prefix = command.deprecatedAlias ? "Deprecated alias; use /role or /roles.\n" : "";
switch (command.kind) { switch (command.kind) {
case "help": return ["Commands:", "/roles", "/role <id>", "/status", "/cancel", "/new", "/help"].join("\n"); case "help": return HELP_TEXT;
case "roles": return `${prefix}Available roles: ${this.roles.list().join(", ")}`;
case "role":
if (!command.argument) return `${prefix}Usage: /role <id>`;
if (!this.roles.has(command.argument)) return `${prefix}Unknown role: ${command.argument}`;
await this.runtime.cancel(message.platform, message.chatId);
await this.runtime.selectRole(message.platform, message.chatId, command.argument);
return `${prefix}Selected role: ${command.argument}`;
case "status": { case "status": {
const status = this.runtime.status(message.platform, message.chatId); const status = this.runtime.status(message.platform, message.chatId, message.userId);
return `OK\n${Object.entries(status).map(([key, value]) => `${key}=${value}`).join("\n")}`; const combined = { ...status, ...this.eventStatus(chatKeyFor(message.platform, message.chatId)) };
return `OK\n${Object.entries(combined).map(([key, value]) => `${key}=${value}`).join("\n")}`;
} }
case "new": case "list": {
await this.runtime.reset(message.platform, message.chatId); if (!this.runtime.listProposals) return "当前运行时不支持列出提案。";
return "Started a new native ACP session for this chat and role."; const chatKey = chatKeyFor(message.platform, message.chatId);
case "cancel": return this.cancelText(message); const owned = (proposal: Proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === message.userId;
return renderProposalPanel(this.runtime.listProposals(message.platform, message.chatId, message.userId), owned);
}
case "confirm": {
if (!this.runtime.confirm) return "当前运行时不支持确认操作。";
return await this.runtime.confirm(message.platform, message.chatId, message.userId) ? "已确认,加入队列。" : "当前没有待确认的提案。";
}
case "finish": {
if (!this.runtime.finish) return "当前运行时不支持结束操作。";
return await this.runtime.finish(message.platform, message.chatId, message.userId) ? "已结束该任务。" : "当前没有待结束的任务。";
}
case "stop": {
if (!this.runtime.stop) return "当前运行时不支持停止操作。";
return await this.runtime.stop(message.platform, message.chatId, message.userId) ? "已停止当前任务,任务转为待确认。" : "当前没有正在执行的任务。";
}
case "cancel":
return await this.runtime.cancel(message.platform, message.chatId, message.userId) ? "已取消最近的提案。" : "没有可取消的提案。";
case "reset": {
const had = await this.runtime.reset(message.platform, message.chatId, message.userId);
if (!had) return "当前没有需要重置的会话。";
const lines = ["已重置当前对话,下条消息开始一个全新的会话。"];
if (this.runtime.listProposals) {
const chatKey = chatKeyFor(message.platform, message.chatId);
const unfinished = this.runtime.listProposals(message.platform, message.chatId, message.userId)
.filter((proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === message.userId)
.filter((proposal) => proposal.status !== "finished").length;
if (unfinished > 0) lines.push(`注意:你还有 ${unfinished} 个未完成任务,proposal 板不受影响。`);
}
return lines.join("\n");
} }
} }
private async cancelText(message: IncomingMessage): Promise<string> {
return await this.runtime.cancel(message.platform, message.chatId) ? "Cancellation requested." : "No active turn to cancel.";
}
private async reply(message: IncomingMessage, adapter: PlatformAdapter, reply: string, options: { synchronous?: boolean }): Promise<GatewayResult> {
if (!options.synchronous) await this.send(message, adapter, reply);
return { ok: true, reply };
}
private send(message: IncomingMessage, adapter: PlatformAdapter, text: string): Promise<void> {
return adapter.sendMessage({ target: { platform: message.platform, chatId: message.chatId, userId: message.userId, raw: message.raw }, text, replyTo: message.messageId });
} }
private checkPolicy(message: IncomingMessage): string | undefined { 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.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: ${message.chatId}`; 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"; if (this.policy.requireMentionInGroup && message.isGroup && !message.mentionsBot) return "Mention required in group chat";
return undefined; return undefined;
} }
private async withChatLock<T>(key: string, fn: () => Promise<T>): Promise<T> { }
const previous = this.locks.get(key) || Promise.resolve();
let release!: () => void; function renderProposalPanel(proposals: Proposal[], owned: (proposal: Proposal) => boolean): string {
const current = new Promise<void>((resolve) => { release = resolve; }); if (proposals.length === 0) return "当前没有提案。";
const queued = previous.then(() => current); // The board is globally visible; ownership is annotated without exposing chat/user IDs.
this.locks.set(key, queued); const scope = (proposal: Proposal) => owned(proposal) ? "(你的)" : "(其他成员)";
await previous; const pending = proposals.filter((proposal) => proposal.status === "pending");
try { return await fn(); } finally { const working = proposals.filter((proposal) => proposal.status === "working");
release(); const queued = proposals.filter((proposal) => proposal.status === "queued");
if (this.locks.get(key) === queued) this.locks.delete(key); const proposed = proposals.filter((proposal) => proposal.status === "proposed");
const finished = proposals.filter((proposal) => proposal.status === "finished")
.sort((left, right) => (right.finishedAt ?? right.updatedAt) - (left.finishedAt ?? left.updatedAt))
.slice(0, 3);
const sections: string[] = [];
if (pending.length > 0) {
sections.push("待确认:");
for (const proposal of pending) {
const card = proposal.pending;
sections.push(`- ${proposal.id}「${proposal.title}」${scope(proposal)}${card ? `:${card.summary}${card.question ? `(问你:${card.question})` : ""}` : ""}(说 /finish 结束,或直接说要求继续改)`);
} }
} }
if (working.length > 0) {
sections.push("进行中:");
for (const proposal of working) sections.push(`- ${proposal.id}「${proposal.title}」${scope(proposal)}正在执行(/stop 可中止)`);
} }
if (queued.length > 0) {
sections.push("排队中:");
for (const proposal of queued) sections.push(`- ${proposal.id}「${proposal.title}」${scope(proposal)}`);
}
if (proposed.length > 0) {
sections.push("未确认(proposed):");
for (const proposal of proposed) sections.push(`- ${proposal.id}「${proposal.title}」${scope(proposal)}(/confirm 确认后排队)`);
}
if (finished.length > 0) {
sections.push("最近结束:");
for (const proposal of finished) {
const state = proposal.finishKind === "cancelled" ? "已取消" : "已完成";
sections.push(`- ${proposal.id}「${proposal.title}」${scope(proposal)}${state}${proposal.finishNote ? `(${proposal.finishNote})` : ""}`);
}
}
return sections.join("\n");
}
function safeEventError(error: unknown): string {
const message = error instanceof Error ? error.message : "";
const http = /HTTP (\d{3})/.exec(message);
const code = /code=(\d+)/.exec(message);
const parts = [http ? `HTTP ${http[1]}` : "", code ? `QQ code ${code[1]}` : ""].filter(Boolean);
if (parts.length > 0) return parts.join(" ");
return error instanceof Error ? error.name : "error";
}
const HELP_TEXT = [
"直接用自然语言告诉我你要做什么即可,我会自己判断并处理。",
"兜底命令:",
"/list 列出当前提案",
"/confirm 确认你最近待确认的提案",
"/finish 结束最近待确认的任务(任何人可执行)",
"/stop 停止正在执行的任务(任何人可执行)",
"/cancel 取消最近未开始或待确认的提案(任何人可执行)",
"/reset 重置当前对话会话",
"/status 查看运行状态"
].join("\n");
+403
View File
@@ -0,0 +1,403 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
export interface StoreIdentity {
botId: string;
platform: string;
}
export interface WorkerProcessGroup {
pgid: number;
token: string;
}
export type ProposalStatus =
| "proposed"
| "queued"
| "working"
| "pending"
| "finished";
export interface ProposalPendingAttachment {
path: string;
mimeType?: string;
}
export interface ProposalPending {
summary: string;
question?: string;
workspaceDirty?: boolean;
receivedAt: number;
attachments?: ProposalPendingAttachment[];
droppedAttachments?: string[];
}
export type ProposalFinishKind = "done" | "cancelled";
export interface Proposal {
id: string;
title: string;
goal: string;
steps: string[];
ownerChatKey: string;
requesterUserId: string;
status: ProposalStatus;
workerNativeSessionId?: string;
workerProcessGroup?: WorkerProcessGroup;
pending?: ProposalPending;
finishKind?: ProposalFinishKind;
finishNote?: string;
lastWorkerSummary?: string;
createdAt: number;
confirmedAt?: number;
startedAt?: number;
updatedAt: number;
finishedAt?: number;
}
export interface CreateProposalInput {
title: string;
goal: string;
steps: string[];
ownerChatKey: string;
requesterUserId: string;
}
export interface ProposalPatch {
title?: string;
goal?: string;
steps?: string[];
status?: ProposalStatus;
workerNativeSessionId?: string;
workerProcessGroup?: WorkerProcessGroup;
pending?: ProposalPending;
finishKind?: ProposalFinishKind;
finishNote?: string;
lastWorkerSummary?: string;
confirmedAt?: number;
startedAt?: number;
finishedAt?: number;
}
interface StoreData {
version: 2;
botId: string;
platform: string;
proposals: Record<string, Proposal>;
}
export class ProposalStore {
private data: StoreData;
private queue: Promise<void> = Promise.resolve();
private lockFd?: fs.promises.FileHandle;
private closed = false;
constructor(readonly file: string, readonly identity: StoreIdentity) {
this.data = emptyData(identity);
}
async open(): Promise<void> {
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);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST" || !await removeStaleLock(lockFile)) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Proposal 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(`Proposal 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 value = JSON.parse(raw) as unknown;
if (!isRecord(value) || typeof value.botId !== "string" || typeof value.platform !== "string") {
throw new Error("unsupported proposal state; expected a versioned store file");
}
if (value.botId !== this.identity.botId || value.platform !== this.identity.platform) {
throw new Error(`proposal state identity mismatch: expected bot '${this.identity.botId}' platform '${this.identity.platform}'`);
}
if (value.version === 1) {
await this.backupFile(raw);
this.data = migrateV1(value);
await this.persist();
} else {
this.data = parseStoreData(value);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
await this.releaseLock();
throw new Error(`Cannot read proposal state '${this.file}'; original file was preserved: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
get(id: string): Proposal | undefined {
const value = this.data.proposals[id];
return value ? structuredClone(value) : undefined;
}
list(filter?: { status?: ProposalStatus }): Proposal[] {
const proposals = Object.values(this.data.proposals)
.filter((proposal) => !filter?.status || proposal.status === filter.status)
.sort((a, b) => (a.confirmedAt ?? a.createdAt) - (b.confirmedAt ?? b.createdAt));
return structuredClone(proposals);
}
async create(input: CreateProposalInput): Promise<Proposal> {
if (typeof input.title !== "string" || input.title.length === 0) throw new Error("proposal title must be a non-empty string");
if (typeof input.goal !== "string" || input.goal.length === 0) throw new Error("proposal goal must be a non-empty string");
if (!Array.isArray(input.steps) || input.steps.some((step) => typeof step !== "string" || step.length === 0)) {
throw new Error("proposal steps must be an array of non-empty strings");
}
if (typeof input.ownerChatKey !== "string" || input.ownerChatKey.length === 0) throw new Error("proposal ownerChatKey must be a non-empty string");
if (typeof input.requesterUserId !== "string" || input.requesterUserId.length === 0) throw new Error("proposal requesterUserId must be a non-empty string");
const now = Date.now();
const proposal: Proposal = {
id: crypto.randomUUID(),
title: input.title,
goal: input.goal,
steps: [...input.steps],
ownerChatKey: input.ownerChatKey,
requesterUserId: input.requesterUserId,
status: "proposed",
createdAt: now,
updatedAt: now
};
this.data.proposals[proposal.id] = proposal;
await this.persist();
return structuredClone(proposal);
}
async update(id: string, patch: ProposalPatch): Promise<Proposal> {
const proposal = this.data.proposals[id];
if (!proposal) throw new Error(`unknown proposal '${id}'`);
const candidate: Proposal = { ...structuredClone(proposal), ...structuredClone(patch), id, updatedAt: Date.now() };
for (const key of Object.keys(candidate) as (keyof Proposal)[]) {
if (candidate[key] === undefined) delete candidate[key];
}
validateProposal(candidate, id);
this.data.proposals[id] = candidate;
await this.persist();
return structuredClone(candidate);
}
stats(): { proposals: number } {
return { proposals: Object.keys(this.data.proposals).length };
}
async flush(): Promise<void> { await this.queue; }
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
try { await this.flush(); } finally { await this.releaseLock(); }
}
private persist(): Promise<void> {
if (this.closed) return Promise.reject(new Error("Proposal store is closed"));
const snapshot = JSON.stringify(this.data, null, 2) + "\n";
const operation = this.queue.then(() => atomicWrite(this.file, snapshot));
this.queue = operation.catch(() => undefined);
return operation;
}
private async backupFile(raw: string): Promise<void> {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const backup = `${this.file}.v1-${stamp}.bak`;
let handle: fs.promises.FileHandle | undefined;
try {
handle = await fs.promises.open(backup, "wx", 0o600);
await handle.writeFile(raw, "utf8");
await handle.sync();
} finally {
await handle?.close().catch(() => undefined);
}
}
private async releaseLock(): Promise<void> {
if (!this.lockFd) return;
await this.lockFd.close();
this.lockFd = undefined;
await fs.promises.unlink(`${this.file}.lock`).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error;
});
}
}
function emptyData(identity: StoreIdentity): StoreData {
return { version: 2, botId: identity.botId, platform: identity.platform, proposals: {} };
}
const STATUSES: readonly ProposalStatus[] = ["proposed", "queued", "working", "pending", "finished"];
const FINISH_KINDS: readonly ProposalFinishKind[] = ["done", "cancelled"];
function parseStoreData(value: unknown): StoreData {
if (!isRecord(value) || value.version !== 2 || typeof value.botId !== "string" || typeof value.platform !== "string" || !isRecord(value.proposals)) {
throw new Error("unsupported proposal state version; expected version 2");
}
for (const [id, proposal] of Object.entries(value.proposals)) {
if (!isRecord(proposal) || proposal.id !== id) throw new Error(`invalid proposal '${id}'`);
validateProposal(proposal as unknown as Proposal, id);
}
return value as unknown as StoreData;
}
interface V1Pending {
kind?: string;
summary?: string;
question?: string;
}
// v1 → v2 migration: pending becomes a single "waiting for the user" state with no success/failure
// distinction; terminal v1 statuses collapse into finished with a finishKind.
function migrateV1(value: Record<string, unknown>): StoreData {
if (!isRecord(value.proposals)) throw new Error("unsupported proposal state version; expected version 2");
const proposals: Record<string, Proposal> = {};
for (const [id, raw] of Object.entries(value.proposals)) {
if (!isRecord(raw) || raw.id !== id) throw new Error(`invalid proposal '${id}'`);
const source = structuredClone(raw) as unknown as Omit<Proposal, "status" | "pending"> & { status: string; pending?: V1Pending };
const pending = isRecord(source.pending) ? source.pending as V1Pending : undefined;
const migrated: Proposal = { ...source, pending: undefined, finishKind: undefined, finishNote: undefined } as Proposal;
if (source.status === "awaiting_user_confirmation") {
migrated.status = "pending";
migrated.pending = {
summary: typeof pending?.summary === "string" ? pending.summary : (source.lastWorkerSummary || "worker reported a result before migration"),
receivedAt: typeof source.updatedAt === "number" ? source.updatedAt : Date.now()
};
if (pending?.kind === "failure") migrated.pending.workspaceDirty = true;
if (pending?.kind === "step" && typeof pending.question === "string") migrated.pending.question = pending.question;
} else if (source.status === "completed") {
migrated.status = "finished";
migrated.finishKind = "done";
migrated.finishedAt = typeof source.finishedAt === "number" ? source.finishedAt : Date.now();
} else if (source.status === "failed") {
migrated.status = "finished";
migrated.finishKind = "done";
migrated.finishNote = pending?.summary || source.lastWorkerSummary || "failed before migration";
migrated.finishedAt = typeof source.finishedAt === "number" ? source.finishedAt : Date.now();
} else if (source.status === "cancelled") {
migrated.status = "finished";
migrated.finishKind = "cancelled";
migrated.finishedAt = typeof source.finishedAt === "number" ? source.finishedAt : Date.now();
} else if (source.status === "proposed" || source.status === "queued" || source.status === "working") {
migrated.status = source.status;
} else {
throw new Error(`invalid proposal '${id}': status`);
}
validateProposal(migrated, id);
proposals[id] = migrated;
}
return { version: 2, botId: value.botId as string, platform: value.platform as string, proposals };
}
function validateProposal(proposal: Proposal, id: string): void {
const invalid = (reason: string): never => { throw new Error(`invalid proposal '${id}': ${reason}`); };
if (typeof proposal.title !== "string" || proposal.title.length === 0) invalid("title");
if (typeof proposal.goal !== "string" || proposal.goal.length === 0) invalid("goal");
if (!Array.isArray(proposal.steps) || proposal.steps.some((step) => typeof step !== "string" || step.length === 0)) invalid("steps");
if (typeof proposal.ownerChatKey !== "string" || proposal.ownerChatKey.length === 0) invalid("ownerChatKey");
if (typeof proposal.requesterUserId !== "string" || proposal.requesterUserId.length === 0) invalid("requesterUserId");
if (!STATUSES.includes(proposal.status)) invalid("status");
if (proposal.workerNativeSessionId !== undefined && typeof proposal.workerNativeSessionId !== "string") invalid("workerNativeSessionId");
if (proposal.workerProcessGroup !== undefined) {
const group = proposal.workerProcessGroup;
if (!isRecord(group) || !Number.isSafeInteger(group.pgid) || group.pgid <= 1 || typeof group.token !== "string" || group.token.length === 0) {
invalid("workerProcessGroup");
}
}
if (proposal.pending !== undefined) {
const pending = proposal.pending;
if (!isRecord(pending)
|| typeof pending.summary !== "string"
|| (pending.question !== undefined && typeof pending.question !== "string")
|| (pending.workspaceDirty !== undefined && typeof pending.workspaceDirty !== "boolean")
|| typeof pending.receivedAt !== "number") {
invalid("pending");
}
if (pending.attachments !== undefined
&& (!Array.isArray(pending.attachments) || pending.attachments.length > 3
|| pending.attachments.some((attachment) => !isRecord(attachment)
|| typeof attachment.path !== "string" || attachment.path.length === 0
|| (attachment.mimeType !== undefined && typeof attachment.mimeType !== "string")))) {
invalid("pending.attachments");
}
if (pending.droppedAttachments !== undefined
&& (!Array.isArray(pending.droppedAttachments) || pending.droppedAttachments.some((entry) => typeof entry !== "string"))) {
invalid("pending.droppedAttachments");
}
}
if (proposal.finishKind !== undefined && !FINISH_KINDS.includes(proposal.finishKind)) invalid("finishKind");
if (proposal.finishNote !== undefined && typeof proposal.finishNote !== "string") invalid("finishNote");
if (proposal.lastWorkerSummary !== undefined && typeof proposal.lastWorkerSummary !== "string") invalid("lastWorkerSummary");
if (typeof proposal.createdAt !== "number") invalid("createdAt");
if (typeof proposal.updatedAt !== "number") invalid("updatedAt");
if (proposal.confirmedAt !== undefined && typeof proposal.confirmedAt !== "number") invalid("confirmedAt");
if (proposal.startedAt !== undefined && typeof proposal.startedAt !== "number") invalid("startedAt");
if (proposal.finishedAt !== undefined && typeof proposal.finishedAt !== "number") invalid("finishedAt");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function acquireLock(lockFile: string): Promise<fs.promises.FileHandle> {
return fs.promises.open(lockFile, "wx", 0o600);
}
async function removeStaleLock(lockFile: string): Promise<boolean> {
let handle: fs.promises.FileHandle | undefined;
try {
handle = await fs.promises.open(lockFile, "r");
const pid = Number((await handle.readFile("utf8")).trim());
if (!Number.isSafeInteger(pid) || pid <= 1 || processExists(pid)) return false;
const opened = await handle.stat();
const current = await fs.promises.lstat(lockFile);
if (opened.dev !== current.dev || opened.ino !== current.ino) return false;
await fs.promises.unlink(lockFile);
return true;
} catch { return false; }
finally { await handle?.close().catch(() => undefined); }
}
function processExists(pid: number): boolean {
try { process.kill(pid, 0); return true; }
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
}
async function atomicWrite(file: string, content: string): Promise<void> {
const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
let handle: fs.promises.FileHandle | undefined;
try {
handle = await fs.promises.open(temp, "wx", 0o600);
await handle.writeFile(content, "utf8");
await handle.sync();
await handle.close();
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;
}
}
+22
View File
@@ -10,6 +10,12 @@ export interface MessageTarget {
raw?: unknown; raw?: unknown;
} }
export interface IncomingAttachment {
mimeType: string;
data: string; // base64
filename?: string;
}
export interface IncomingMessage { export interface IncomingMessage {
platform: PlatformName; platform: PlatformName;
chatId: string; chatId: string;
@@ -18,13 +24,29 @@ export interface IncomingMessage {
messageId?: string; messageId?: string;
isGroup?: boolean; isGroup?: boolean;
mentionsBot?: boolean; mentionsBot?: boolean;
attachments?: IncomingAttachment[];
raw?: unknown; raw?: unknown;
} }
export interface OutgoingImage {
mimeType: string;
data: string; // base64
filename?: string;
}
// A reference to an image file on disk (inside bot.workspace); resolved to OutgoingImage at send time.
export interface OutgoingImageRef {
path: string;
mimeType?: string;
filename?: string;
}
export interface OutgoingMessage { export interface OutgoingMessage {
target: MessageTarget; target: MessageTarget;
text: string; text: string;
replyTo?: string; replyTo?: string;
replySequence?: number;
images?: OutgoingImage[];
} }
export interface WebhookRequestContext { export interface WebhookRequestContext {
+66
View File
@@ -0,0 +1,66 @@
import fs from "node:fs";
import path from "node:path";
import type { OutgoingImage, OutgoingImageRef } from "./types.js";
// Outbound images (Worker screenshots etc.) must live inside bot.workspace and be png or jpg.
export const MAX_OUTBOUND_IMAGE_BYTES = 10 * 1024 * 1024;
export const MAX_OUTBOUND_IMAGES = 3;
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const JPG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]);
export interface ValidatedWorkspaceImage {
path: string; // resolved real path inside the workspace
mimeType: "image/png" | "image/jpeg";
filename: string;
}
// Resolves a worker/assistant-reported image path against the workspace and validates
// containment, size, and magic bytes. Returns undefined (never throws) when invalid.
export async function validateWorkspaceImage(workspace: string, refPath: string): Promise<ValidatedWorkspaceImage | undefined> {
if (typeof refPath !== "string" || refPath.length === 0) return undefined;
try {
const root = fs.realpathSync.native(workspace);
const candidate = path.resolve(root, refPath);
const resolved = await fs.promises.realpath(candidate);
const relative = path.relative(root, resolved);
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return undefined;
const stat = await fs.promises.lstat(resolved);
if (!stat.isFile() || stat.isSymbolicLink() || stat.size === 0 || stat.size > MAX_OUTBOUND_IMAGE_BYTES) return undefined;
const handle = await fs.promises.open(resolved, "r");
let header: Buffer;
try {
header = Buffer.alloc(PNG_MAGIC.length);
const { bytesRead } = await handle.read(header, 0, PNG_MAGIC.length, 0);
header = header.subarray(0, bytesRead);
} finally {
await handle.close();
}
const mimeType = magicMimeType(header);
if (!mimeType) return undefined;
return { path: resolved, mimeType, filename: path.basename(resolved) };
} catch {
return undefined;
}
}
// Re-reads a previously validated image at send time (the worker may have updated it);
// re-checks type and size and returns undefined when the file is gone or no longer valid.
export async function readOutboundImage(ref: OutgoingImageRef): Promise<OutgoingImage | undefined> {
try {
const stat = await fs.promises.lstat(ref.path);
if (!stat.isFile() || stat.isSymbolicLink() || stat.size === 0 || stat.size > MAX_OUTBOUND_IMAGE_BYTES) return undefined;
const data = await fs.promises.readFile(ref.path);
const mimeType = magicMimeType(data.subarray(0, PNG_MAGIC.length));
if (!mimeType) return undefined;
return { mimeType, data: data.toString("base64"), filename: ref.filename || path.basename(ref.path) };
} catch {
return undefined;
}
}
function magicMimeType(header: Buffer): "image/png" | "image/jpeg" | undefined {
if (header.length >= PNG_MAGIC.length && header.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC)) return "image/png";
if (header.length >= JPG_MAGIC.length && header.subarray(0, JPG_MAGIC.length).equals(JPG_MAGIC)) return "image/jpeg";
return undefined;
}
+37
View File
@@ -0,0 +1,37 @@
import fs from "node:fs";
import path from "node:path";
import { parseConfig } from "../config.js";
export function canonicalWorkspace(workspace: string): string {
return fs.realpathSync.native(path.resolve(workspace));
}
export function findOverlappingWorkspace(workspace: string, botId: string, instancesDirectory: string): string | undefined {
const target = canonicalWorkspace(workspace);
let entries: fs.Dirent[];
try { entries = fs.readdirSync(instancesDirectory, { withFileTypes: true }); }
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
for (const entry of entries) {
if (entry.name === botId) continue;
if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error(`Refusing unsafe peer instance entry: ${entry.name}`);
const file = path.join(instancesDirectory, entry.name, "config.json");
let stat: fs.Stats;
try { stat = fs.lstatSync(file); }
catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error(`Peer instance is missing config.json: ${entry.name}`);
throw error;
}
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Refusing unsafe peer config: ${file}`);
let peer;
try { peer = parseConfig(JSON.parse(fs.readFileSync(file, "utf8"))); }
catch (error) { throw new Error(`Invalid peer Config v3 '${file}': ${error instanceof Error ? error.message : String(error)}`); }
const other = canonicalWorkspace(peer.bot.workspace);
if (other === target || isInside(other, target) || isInside(target, other)) return entry.name;
}
return undefined;
}
function isInside(parent: string, child: string): boolean {
const relative = path.relative(parent, child);
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
}
+4 -8
View File
@@ -1,4 +1,4 @@
import type { AppConfig } from "../../config.js"; import type { FeishuConfig } from "../../config.js";
import { jsonResponse } from "../../core/adapter.js"; import { jsonResponse } from "../../core/adapter.js";
import type { PlatformAdapter } from "../../core/adapter.js"; import type { PlatformAdapter } from "../../core/adapter.js";
import type { Gateway } from "../../core/gateway.js"; import type { Gateway } from "../../core/gateway.js";
@@ -11,7 +11,7 @@ export class FeishuAdapter implements PlatformAdapter {
private tenantToken?: { token: string; expiresAt: number }; private tenantToken?: { token: string; expiresAt: number };
constructor( constructor(
private readonly config: AppConfig["platforms"]["feishu"], private readonly config: FeishuConfig,
private readonly gateway: Gateway private readonly gateway: Gateway
) {} ) {}
@@ -72,9 +72,7 @@ export class FeishuAdapter implements PlatformAdapter {
}) })
}); });
if (!response.ok) { if (!response.ok) throw new Error(`Feishu reply failed: HTTP ${response.status}`);
throw new Error(`Feishu reply failed: ${response.status} ${await response.text()}`);
}
const data = await response.json() as { code?: number; msg?: string }; const data = await response.json() as { code?: number; msg?: string };
if (data.code && data.code !== 0) { if (data.code && data.code !== 0) {
throw new Error(`Feishu reply failed: ${data.code} ${data.msg || ""}`.trim()); 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" }, headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ app_id: this.config.appId, app_secret: this.config.appSecret }) body: JSON.stringify({ app_id: this.config.appId, app_secret: this.config.appSecret })
}); });
if (!response.ok) { if (!response.ok) throw new Error(`Feishu token request failed: HTTP ${response.status}`);
throw new Error(`Feishu token request failed: ${response.status} ${await response.text()}`);
}
const data = await response.json() as FeishuTenantTokenResponse; const data = await response.json() as FeishuTenantTokenResponse;
if (data.code !== 0 || !data.tenant_access_token) { if (data.code !== 0 || !data.tenant_access_token) {
throw new Error(`Feishu token request failed: ${data.code} ${data.msg || ""}`.trim()); throw new Error(`Feishu token request failed: ${data.code} ${data.msg || ""}`.trim());
+130 -19
View File
@@ -1,9 +1,9 @@
import type { AppConfig } from "../../config.js"; import type { QqConfig } from "../../config.js";
import { jsonResponse } from "../../core/adapter.js"; import { jsonResponse } from "../../core/adapter.js";
import type { PlatformAdapter } from "../../core/adapter.js"; import type { PlatformAdapter } from "../../core/adapter.js";
import type { Gateway } from "../../core/gateway.js"; import type { Gateway } from "../../core/gateway.js";
import { stripBotMentions } from "../../core/text.js"; import { stripBotMentions } from "../../core/text.js";
import type { IncomingMessage, OutgoingMessage, WebhookRequestContext, WebhookResponse } from "../../core/types.js"; import type { IncomingAttachment, IncomingMessage, OutgoingMessage, WebhookRequestContext, WebhookResponse } from "../../core/types.js";
import { signQqValidation, verifyQqWebhookSignature } from "./crypto.js"; import { signQqValidation, verifyQqWebhookSignature } from "./crypto.js";
import type { QqAccessTokenResponse, QqSendMessageResponse, QqWebhookEventData, QqWebhookPayload } from "./types.js"; import type { QqAccessTokenResponse, QqSendMessageResponse, QqWebhookEventData, QqWebhookPayload } from "./types.js";
@@ -12,13 +12,19 @@ const MESSAGE_EVENTS = new Set([
"GROUP_AT_MESSAGE_CREATE", "GROUP_AT_MESSAGE_CREATE",
"C2C_MESSAGE_CREATE" "C2C_MESSAGE_CREATE"
]); ]);
// Inbound attachment limits: only images are downloaded (base64 passthrough to the agent);
// everything else degrades to a text link.
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
const MAX_IMAGES_PER_MESSAGE = 3;
const ATTACHMENT_DOWNLOAD_TIMEOUT_MS = 10_000;
export class QqAdapter implements PlatformAdapter { export class QqAdapter implements PlatformAdapter {
readonly name = "qq"; readonly name = "qq";
readonly supportsImages = true;
private accessToken?: { token: string; expiresAt: number }; private accessToken?: { token: string; expiresAt: number };
constructor( constructor(
private readonly config: AppConfig["platforms"]["qq"], private readonly config: QqConfig,
private readonly gateway: Gateway private readonly gateway: Gateway
) {} ) {}
@@ -38,17 +44,17 @@ export class QqAdapter implements PlatformAdapter {
return jsonResponse(QQ_CALLBACK_ACK); return jsonResponse(QQ_CALLBACK_ACK);
} }
if (!this.handleDispatch(payload)) return jsonResponse(QQ_CALLBACK_ACK); await this.handleDispatch(payload);
return jsonResponse(QQ_CALLBACK_ACK); return jsonResponse(QQ_CALLBACK_ACK);
} }
handleDispatch(payload: QqWebhookPayload): boolean { async handleDispatch(payload: QqWebhookPayload): Promise<boolean> {
const message = this.normalizeMessage(payload); const message = await this.normalizeMessage(payload);
if (!message) { if (!message) {
console.log(`QQ recv ${payload.t} ignored (empty text or missing ids)`); console.log(`QQ recv ${payload.t} ignored (empty text or missing ids)`);
return false; 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} images=${message.attachments?.length || 0}`);
void this.gateway.receive(message, this).catch((error) => { void this.gateway.receive(message, this).catch((error) => {
console.error("QQ gateway error", error); console.error("QQ gateway error", error);
}); });
@@ -63,20 +69,66 @@ export class QqAdapter implements PlatformAdapter {
const userOpenId = stringField(raw.user_openid) || stringField(author.user_openid); const userOpenId = stringField(raw.user_openid) || stringField(author.user_openid);
const isGroup = Boolean(groupOpenId) || message.target.chatId.startsWith("group:"); const isGroup = Boolean(groupOpenId) || message.target.chatId.startsWith("group:");
const targetId = groupOpenId || userOpenId || message.target.chatId.replace(/^group:/, "").replace(/^user:/, ""); const targetId = groupOpenId || userOpenId || message.target.chatId.replace(/^group:/, "").replace(/^user:/, "");
const path = isGroup ? `/v2/groups/${encodeURIComponent(targetId)}/messages` : `/v2/users/${encodeURIComponent(targetId)}/messages`; const base = isGroup ? `/v2/groups/${encodeURIComponent(targetId)}` : `/v2/users/${encodeURIComponent(targetId)}`;
console.log(`QQ send -> ${path} msgId=${message.replyTo || ""} length=${message.text.length}`);
const response = await fetch(`https://api.sgroup.qq.com${path}`, { // Outbound images: upload via /files (group uploads can only be sent to groups, user
// uploads only to C2C — the base path already matches the target), then send msg_type 7.
for (const image of message.images || []) {
await this.sendImageMessage(token, base, isGroup, image, message);
}
if (message.images?.length && !message.text) return;
console.log(`QQ send ${isGroup ? "group" : "user"} message length=${message.text.length}`);
// Passive replies reference the inbound message; proactive messages must send neither msg_id nor msg_seq.
const body: Record<string, unknown> = { content: message.text };
if (message.replyTo) {
body.msg_id = message.replyTo;
body.msg_seq = message.replySequence ?? 1;
}
const response = await fetch(`https://api.sgroup.qq.com${base}/messages`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `QQBot ${token}`, Authorization: `QQBot ${token}`,
"Content-Type": "application/json; charset=utf-8" "Content-Type": "application/json; charset=utf-8"
}, },
body: JSON.stringify({ content: message.text, msg_id: message.replyTo }) body: JSON.stringify(body)
}); });
if (!response.ok) throw new Error(`QQ send failed: ${response.status} ${await response.text()}`); const data = await response.json().catch(() => undefined) as QqSendMessageResponse | undefined;
const data = await response.json() as QqSendMessageResponse; // Keep only safe error details (HTTP status / QQ code / QQ message); never log the request body.
if (data.code && data.code !== 0) throw new Error(`QQ send failed: ${data.code} ${data.message || ""}`.trim()); if (!response.ok) throw new Error(`QQ send failed: HTTP ${response.status}${qqErrorDetail(data)}`);
if (data?.code && data.code !== 0) throw new Error(`QQ send failed:${qqErrorDetail(data)}`);
}
private async sendImageMessage(token: string, base: string, isGroup: boolean, image: { mimeType: string; data: string; filename?: string }, message: OutgoingMessage): Promise<void> {
console.log(`QQ send ${isGroup ? "group" : "user"} image type=${image.mimeType} bytes=${Math.floor(image.data.length * 3 / 4)}`);
const headers = {
Authorization: `QQBot ${token}`,
"Content-Type": "application/json; charset=utf-8"
};
const upload = await fetch(`https://api.sgroup.qq.com${base}/files`, {
method: "POST",
headers,
body: JSON.stringify({ file_type: 1, file_data: image.data, srv_send_msg: false })
});
const uploaded = await upload.json().catch(() => undefined) as { file_info?: string; code?: number; message?: string } | undefined;
if (!upload.ok) throw new Error(`QQ image upload failed: HTTP ${upload.status}${qqErrorDetail(uploaded)}`);
if (uploaded?.code && uploaded.code !== 0) throw new Error(`QQ image upload failed:${qqErrorDetail(uploaded)}`);
if (!uploaded?.file_info) throw new Error("QQ image upload failed: missing file_info");
const body: Record<string, unknown> = { msg_type: 7, media: { file_info: uploaded.file_info }, content: "" };
if (message.replyTo) {
body.msg_id = message.replyTo;
body.msg_seq = message.replySequence ?? 1;
}
const response = await fetch(`https://api.sgroup.qq.com${base}/messages`, {
method: "POST",
headers,
body: JSON.stringify(body)
});
const data = await response.json().catch(() => undefined) as QqSendMessageResponse | undefined;
if (!response.ok) throw new Error(`QQ image send failed: HTTP ${response.status}${qqErrorDetail(data)}`);
if (data?.code && data.code !== 0) throw new Error(`QQ image send failed:${qqErrorDetail(data)}`);
} }
private handleValidation(data: QqWebhookEventData | undefined): WebhookResponse { private handleValidation(data: QqWebhookEventData | undefined): WebhookResponse {
@@ -91,27 +143,52 @@ export class QqAdapter implements PlatformAdapter {
}); });
} }
private normalizeMessage(payload: QqWebhookPayload): IncomingMessage | undefined { private async normalizeMessage(payload: QqWebhookPayload): Promise<IncomingMessage | undefined> {
const data = payload.d; const data = payload.d;
if (!data) return undefined; if (!data) return undefined;
const rawText = data.content || data.text || ""; const rawText = data.content || data.text || "";
const text = stripBotMentions(stripConfiguredBotNames(rawText, this.config.botNames)); const text = stripBotMentions(stripConfiguredBotNames(rawText, this.config.botNames));
if (!text) return undefined; const attachments = Array.isArray(data.attachments) ? data.attachments : [];
const linkLines: string[] = [];
const imageSources = attachments.filter((attachment) => {
const type = typeof attachment?.content_type === "string" ? attachment.content_type : "";
if (type.startsWith("image/")) return typeof attachment.url === "string" && attachment.url.length > 0;
if (typeof attachment?.url === "string" && attachment.url) {
linkLines.push(type.startsWith("video/") ? `[视频] ${attachment.url}` : `[文件] ${attachment.url}`);
}
return false;
});
const images: IncomingAttachment[] = [];
const downloadable = imageSources.filter((source) => !(typeof source.size === "number" && source.size > MAX_IMAGE_BYTES));
let skippedImages = imageSources.length - downloadable.length + Math.max(0, downloadable.length - MAX_IMAGES_PER_MESSAGE);
for (const source of downloadable.slice(0, MAX_IMAGES_PER_MESSAGE)) {
const image = await downloadImage(source);
if (image) images.push(image);
else skippedImages++;
}
if (attachments.length > 0) {
console.log(`QQ attachments: total=${attachments.length} images=${imageSources.length} downloaded=${images.length} skipped=${skippedImages} links=${linkLines.length}`);
}
const parts = [text, ...linkLines].filter((part) => part.length > 0);
if (parts.length === 0 && images.length > 0) parts.push(images.length > 1 ? `(发来 ${images.length} 张图片)` : "(发来一张图片)");
if (parts.length === 0) return undefined;
const isGroup = payload.t === "GROUP_AT_MESSAGE_CREATE" || payload.t === "AT_MESSAGE_CREATE" || Boolean(data.group_openid || data.group_id || data.channel_id || data.guild_id); const isGroup = payload.t === "GROUP_AT_MESSAGE_CREATE" || payload.t === "AT_MESSAGE_CREATE" || Boolean(data.group_openid || data.group_id || data.channel_id || data.guild_id);
const chatId = chatIdFor(data, isGroup); const chatId = chatIdFor(data, isGroup);
const userId = data.user_openid || data.author?.user_openid || data.author?.id || data.member_openid; const userId = userIdFor(data, isGroup);
if (!chatId || !userId) return undefined; if (!chatId || !userId) return undefined;
return { return {
platform: this.name, platform: this.name,
chatId, chatId,
userId, userId,
text, text: parts.join("\n"),
messageId: data.id || data.msg_id || data.message_id || payload.id, messageId: data.id || data.msg_id || data.message_id || payload.id,
isGroup, isGroup,
mentionsBot: isGroup || this.config.botNames.some((name) => rawText.includes(`@${name}`) || rawText.includes(name)), mentionsBot: isGroup || this.config.botNames.some((name) => rawText.includes(`@${name}`) || rawText.includes(name)),
attachments: images.length > 0 ? images : undefined,
raw: data raw: data
}; };
} }
@@ -128,7 +205,7 @@ export class QqAdapter implements PlatformAdapter {
headers: { "Content-Type": "application/json; charset=utf-8" }, headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ appId: this.config.appId, clientSecret: this.config.clientSecret }) 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; const data = await response.json() as QqAccessTokenResponse;
if (!data.access_token) { if (!data.access_token) {
throw new Error(`QQ token request failed: ${data.error || "unknown"} ${data.error_description || ""}`.trim()); throw new Error(`QQ token request failed: ${data.error || "unknown"} ${data.error_description || ""}`.trim());
@@ -141,6 +218,40 @@ export class QqAdapter implements PlatformAdapter {
} }
} }
async function downloadImage(attachment: { content_type?: string; filename?: string; url?: string }): Promise<IncomingAttachment | undefined> {
const rawUrl = typeof attachment.url === "string" ? attachment.url : "";
const url = rawUrl.startsWith("//") ? `https:${rawUrl}` : rawUrl;
if (!/^https?:\/\//.test(url)) return undefined;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ATTACHMENT_DOWNLOAD_TIMEOUT_MS);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) return undefined;
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length === 0 || buffer.length > MAX_IMAGE_BYTES) return undefined;
return {
mimeType: attachment.content_type || "image/*",
data: buffer.toString("base64"),
filename: typeof attachment.filename === "string" ? attachment.filename : undefined
};
} catch {
return undefined;
} finally {
clearTimeout(timer);
}
}
function qqErrorDetail(data: QqSendMessageResponse | undefined): string {
if (!data) return "";
const parts = [data.code ? `code=${data.code}` : "", data.message || ""].filter(Boolean);
return parts.length > 0 ? ` ${parts.join(" ")}` : "";
}
function userIdFor(data: QqWebhookEventData, isGroup: boolean): string | undefined {
if (isGroup) return data.author?.member_openid || data.member_openid || data.author?.user_openid || data.user_openid || data.author?.id;
return data.author?.user_openid || data.user_openid || data.author?.id || data.member_openid || data.author?.member_openid;
}
function chatIdFor(data: QqWebhookEventData, isGroup: boolean): string | undefined { function chatIdFor(data: QqWebhookEventData, isGroup: boolean): string | undefined {
if (data.group_openid) return `group:${data.group_openid}`; if (data.group_openid) return `group:${data.group_openid}`;
if (data.group_id) return `group:${data.group_id}`; if (data.group_id) return `group:${data.group_id}`;
+4 -4
View File
@@ -1,6 +1,6 @@
import os from "node:os"; import os from "node:os";
import WebSocket from "ws"; import WebSocket from "ws";
import type { AppConfig } from "../../config.js"; import type { QqConfig } from "../../config.js";
import type { QqAdapter } from "./adapter.js"; import type { QqAdapter } from "./adapter.js";
import type { QqGatewayResponse, QqWebhookPayload } from "./types.js"; import type { QqGatewayResponse, QqWebhookPayload } from "./types.js";
@@ -19,7 +19,7 @@ export class QqGatewayClient {
private stopped = false; private stopped = false;
constructor( constructor(
private readonly config: AppConfig["platforms"]["qq"], private readonly config: QqConfig,
private readonly adapter: QqAdapter private readonly adapter: QqAdapter
) {} ) {}
@@ -91,7 +91,7 @@ export class QqGatewayClient {
return; return;
} }
if (payload.t === "GROUP_AT_MESSAGE_CREATE" || payload.t === "C2C_MESSAGE_CREATE") { if (payload.t === "GROUP_AT_MESSAGE_CREATE" || payload.t === "C2C_MESSAGE_CREATE") {
this.adapter.handleDispatch(payload); void this.adapter.handleDispatch(payload).catch(() => undefined);
} }
return; return;
} }
@@ -113,7 +113,7 @@ export class QqGatewayClient {
const response = await fetch(QQ_GATEWAY_API, { const response = await fetch(QQ_GATEWAY_API, {
headers: { Authorization: `QQBot ${token}` } headers: { Authorization: `QQBot ${token}` }
}); });
if (!response.ok) throw new Error(`QQ gateway request failed: ${response.status} ${await response.text()}`); if (!response.ok) throw new Error(`QQ gateway request failed: HTTP ${response.status}`);
return response.json() as Promise<QqGatewayResponse>; return response.json() as Promise<QqGatewayResponse>;
} }
+7
View File
@@ -47,6 +47,13 @@ export interface QqWebhookEventData {
guild_id?: string; guild_id?: string;
user_openid?: string; user_openid?: string;
member_openid?: string; member_openid?: string;
attachments?: {
content_type?: string;
filename?: string;
id?: string;
size?: number;
url?: string;
}[];
author?: { author?: {
id?: string; id?: string;
user_openid?: string; user_openid?: string;
+2 -2
View File
@@ -1,5 +1,5 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import type { AppConfig } from "../../config.js"; import type { WebhookConfig } from "../../config.js";
import { jsonResponse } from "../../core/adapter.js"; import { jsonResponse } from "../../core/adapter.js";
import type { PlatformAdapter } from "../../core/adapter.js"; import type { PlatformAdapter } from "../../core/adapter.js";
import type { Gateway } from "../../core/gateway.js"; import type { Gateway } from "../../core/gateway.js";
@@ -32,7 +32,7 @@ export class GenericWebhookAdapter implements PlatformAdapter {
readonly name = "webhook"; readonly name = "webhook";
constructor( constructor(
private readonly config: AppConfig["platforms"]["webhook"], private readonly config: WebhookConfig,
private readonly gateway: Gateway private readonly gateway: Gateway
) {} ) {}
+4 -4
View File
@@ -1,4 +1,4 @@
import type { AppConfig } from "../../config.js"; import type { WeComConfig } from "../../config.js";
import { notImplemented } from "../../core/adapter.js"; import { notImplemented } from "../../core/adapter.js";
import type { PlatformAdapter } from "../../core/adapter.js"; import type { PlatformAdapter } from "../../core/adapter.js";
import type { OutgoingMessage, WebhookRequestContext, WebhookResponse } from "../../core/types.js"; import type { OutgoingMessage, WebhookRequestContext, WebhookResponse } from "../../core/types.js";
@@ -8,7 +8,7 @@ export class WeComAdapter implements PlatformAdapter {
readonly name = "wecom"; readonly name = "wecom";
private accessToken?: { token: string; expiresAt: number }; private accessToken?: { token: string; expiresAt: number };
constructor(private readonly config: AppConfig["platforms"]["wecom"]) {} constructor(private readonly config: WeComConfig) {}
async handleWebhook(_context: WebhookRequestContext): Promise<WebhookResponse> { async handleWebhook(_context: WebhookRequestContext): Promise<WebhookResponse> {
return notImplemented("WeCom"); return notImplemented("WeCom");
@@ -27,7 +27,7 @@ export class WeComAdapter implements PlatformAdapter {
safe: 0 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; const data = await response.json() as WeComSendMessageResponse;
if (data.errcode !== 0) throw new Error(`WeCom send failed: ${data.errcode} ${data.errmsg || ""}`.trim()); 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("corpid", this.config.corpId);
url.searchParams.set("corpsecret", this.config.secret); url.searchParams.set("corpsecret", this.config.secret);
const response = await fetch(url); 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; const data = await response.json() as WeComAccessTokenResponse;
if (data.errcode !== 0 || !data.access_token) { if (data.errcode !== 0 || !data.access_token) {
throw new Error(`WeCom token request failed: ${data.errcode} ${data.errmsg || ""}`.trim()); throw new Error(`WeCom token request failed: ${data.errcode} ${data.errmsg || ""}`.trim());
+2 -2
View File
@@ -1,4 +1,4 @@
import type { AppConfig } from "../../config.js"; import type { WeixinConfig } from "../../config.js";
import { jsonResponse, notImplemented } from "../../core/adapter.js"; import { jsonResponse, notImplemented } from "../../core/adapter.js";
import type { PlatformAdapter } from "../../core/adapter.js"; import type { PlatformAdapter } from "../../core/adapter.js";
import type { Gateway } from "../../core/gateway.js"; import type { Gateway } from "../../core/gateway.js";
@@ -17,7 +17,7 @@ export class WeixinAdapter implements PlatformAdapter {
readonly name = "weixin"; readonly name = "weixin";
constructor( constructor(
private readonly config: AppConfig["platforms"]["weixin"], private readonly config: WeixinConfig,
private readonly gateway: Gateway private readonly gateway: Gateway
) {} ) {}
+55 -33
View File
@@ -1,51 +1,73 @@
import crypto from "node:crypto"; 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"; import { SkillLoader, type LoadedSkill } from "./skill-loader.js";
export interface ResolvedRole extends RoleConfig { const BOOTSTRAP_SCHEMA_VERSION = 12;
export interface ResolvedBot extends BotConfig {
loadedSkills: LoadedSkill[]; loadedSkills: LoadedSkill[];
fingerprint: string; fingerprint: string;
bootstrap: string; assistantBootstrap: string;
workerBootstrap: string;
} }
export class RoleRegistry { export class BotProfileResolver {
private readonly roles = new Map<string, ResolvedRole>(); readonly bot: ResolvedBot;
constructor(private readonly config: AppConfig) { constructor(config: AppConfig) {
const loader = new SkillLoader(config.skills); const loadedSkills = config.bot.skills.map((skill) => new SkillLoader(config.bot.skills).load(skill.id));
for (const role of config.roles) {
const loadedSkills = role.skills.map((id) => loader.load(id));
const fingerprint = crypto.createHash("sha256").update(JSON.stringify({ const fingerprint = crypto.createHash("sha256").update(JSON.stringify({
id: role.id, bootstrapSchemaVersion: BOOTSTRAP_SCHEMA_VERSION,
backend: role.backend, id: config.bot.id,
workspace: role.workspace, workspace: config.bot.workspace,
persona: role.persona, persona: config.bot.persona,
policy: role.policy, assistantPersona: config.bot.assistantPersona,
agent: config.bot.agent,
permissions: config.bot.permissions,
skills: loadedSkills.map(({ id, file, hash }) => ({ id, file, hash })) skills: loadedSkills.map(({ id, file, hash }) => ({ id, file, hash }))
})).digest("hex"); })).digest("hex");
this.roles.set(role.id, { ...role, loadedSkills, fingerprint, bootstrap: buildBootstrap(role, loadedSkills) }); this.bot = {
...config.bot,
loadedSkills,
fingerprint,
assistantBootstrap: buildAssistantBootstrap(config.bot),
workerBootstrap: buildWorkerBootstrap(config.bot, loadedSkills)
};
} }
} }
get(id?: string): ResolvedRole { function buildAssistantBootstrap(bot: BotConfig): string {
const roleId = id || this.config.defaultRole; const persona = bot.assistantPersona || bot.persona;
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 {
return [ return [
"Initialize this ACP session with the following role. Treat these instructions as persistent context. Reply only with READY.", `Initialize this ACP session with gori-agent assistant bootstrap schema v${BOOTSTRAP_SCHEMA_VERSION}. Treat these instructions as persistent context. Reply only with READY.`,
`Role: ${role.id}`, `Bot: ${bot.id}`,
`Workspace: ${role.workspace}`, `Workspace: ${bot.workspace}`,
role.persona ? `Persona:\n${role.persona}` : "Persona: general coding assistant", persona ? `Persona:\n${persona}` : "Persona: general coding assistant",
`Permission policy enforced by the ACP client: ${JSON.stringify(role.policy)}`, "You are the user-facing Assistant. You have no tools and cannot inspect files, run commands, call Skills or MCP. You chat with the user, propose work, and explain worker feedback. Never claim to have executed anything yourself.",
"Speaking style: talk like a reliable colleague, not a console. Lead with the conclusion, then the reason, then the next step. Avoid protocol jargon and field names; never expose internal words like scheduler, pending, envelope, or action types to the user. Default to 2-4 sentences. Do not repeat proposal IDs unless the user asks. If you are unsure, say so plainly. When something is blocked, always give the user an actionable next step.",
"Every reply must end with exactly one hidden action envelope: <GORI_ASSISTANT_ACTION_V2>{\"reply\":\"...\",\"actions\":[...]}</GORI_ASSISTANT_ACTION_V2>. Put the user-facing text in the JSON \"reply\" field, not outside the envelope.",
"Supported actions: {\"type\":\"create_proposal\",\"title\":\"...\",\"goal\":\"...\",\"steps\":[\"...\"]}; {\"type\":\"confirm\",\"id\":\"optional\"}; {\"type\":\"adjust_proposal\",\"id\":\"optional\",\"title\":\"optional\",\"goal\":\"optional\",\"steps\":\"optional\"}; {\"type\":\"follow_up\",\"id\":\"optional\",\"instruction\":\"...\"}; {\"type\":\"finish\",\"id\":\"optional\",\"note\":\"optional\"}; {\"type\":\"send_image\",\"path\":\"...\"}; {\"type\":\"start_next\"}; {\"type\":\"cancel\",\"id\":\"optional\"}; {\"type\":\"stop\"}. Use an empty actions array when no state change is needed.",
"A proposal only starts after the user confirms it. Once a proposal is working, do not ask the user to re-confirm ordinary low-risk next steps. The worker should keep executing routine low-risk work until it reaches a real decision point and then return pending with a question. When a worker finishes a turn, the proposal becomes pending: it waits for the user's decision with a summary (and maybe a question). The worker never reports success or failure; treat every result as information for the user. \"finish\" closes a pending proposal as done; \"follow_up\" sends the user's new instruction to the same proposal and resumes its worker; \"cancel\" drops a proposed, queued, or pending proposal; \"stop\" aborts the running worker and leaves the proposal pending; \"start_next\" starts the oldest confirmed queued proposal. \"adjust_proposal\" edits a proposal that has not started yet. Never invent other actions or statuses.",
"When a worker's summary says it saved image files inside the workspace (for example under .gori-outbox/) and the user asks to see one, emit \"send_image\" with that exact path. Only send paths a worker actually reported; never invent paths.",
"Whenever the [Pending proposals] section in a prompt lists one of the user's proposals, your reply MUST acknowledge it: remind the user what is waiting and that they can say finish to close it or just keep talking to continue it.",
"Never claim an action has already taken effect. The runtime executes your actions after your reply and appends a correction to your message when something could not be done (for example when start_next is blocked by another proposal). Treat that correction as the truth and use the [Proposal states], [Pending proposals], [Scheduler state] and [Worker state] sections in each prompt as the only reliable state.",
"The [Proposal states] board is shared globally: you see every unfinished proposal. Entries marked scope=own belong to the current user; scope=other entries belong to someone else. You may honestly describe the whole board to anyone (what the bot is working on, how many tasks are queued). Confirm, adjust_proposal, follow_up, and start_next only apply to scope=own entries. finish, cancel, and stop may target any visible entry when the user explicitly asks you to close, cancel, or stop it.",
"In a group chat each proposal is still owned by the user who requested it for confirm, adjust, follow_up, and start_next. finish, stop, and cancel are shared actions: any user may use them on visible proposals to unblock the single worker and the shared queue.",
"When a proposal is pending and the user says something like \"够了\", \"可以了\", \"结束吧\" or \"that's enough\", emit a \"finish\" action. When they instead ask for more changes or answer the pending question, emit \"follow_up\" with their instruction so the same worker continues."
].join("\n\n");
}
function buildWorkerBootstrap(bot: BotConfig, skills: LoadedSkill[]): string {
return [
`Initialize this ACP session with gori-agent worker 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)}`,
"You are the Worker. You execute exactly one confirmed proposal in the configured workspace and never talk to the user directly.",
"A confirmed proposal is a single permission grant to carry out routine low-risk execution inside the proposal scope. Do not stop for step-by-step confirmation during ordinary low-risk work such as reading files, searching, editing inside the workspace, running local builds, tests, lint, or typecheck, and following the proposal's stated steps. Only return control early when you hit a real decision point: a high-risk action, a key product choice, an external blocker, or an unexpected/dirty target that needs the user's call.",
"For every turn, end your response with exactly one hidden result envelope: <GORI_WORKER_RESULT_V2>{\"status\":\"PENDING\",\"summary\":\"...\"}</GORI_WORKER_RESULT_V2>. PENDING is the only status: it hands the result back to the user. Always include a short user-readable \"summary\" of what you did or what is blocking you. Add a \"question\" when you need the user's decision before continuing. Set \"workspaceDirty\": true when you left the workspace modified or are unsure about its state. When you produced image files the user should see (png/jpg only), save them inside the workspace (prefer .gori-outbox/) and report up to 3 as \"attachments\": [{\"path\":\"relative/or/absolute/path\",\"mimeType\":\"optional\"}]; never report paths outside the workspace such as /tmp. Never emit any other status or text after the envelope.",
"Keep every command and tool process attached to this ACP worker. Never daemonize, call setsid, use nohup, create a detached process, or leave a background process running after the turn.",
...skills.map((skill) => `Skill ${skill.id} (${skill.file}):\n${skill.content}`) ...skills.map((skill) => `Skill ${skill.id} (${skill.file}):\n${skill.content}`)
].join("\n\n"); ].join("\n\n");
} }
+91 -42
View File
@@ -1,103 +1,152 @@
import express from "express"; import express from "express";
import type { Server } from "node:http"; import type { Server } from "node:http";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { AcpBackendRegistry } from "./acp/backend-registry.js"; import { AssistantManager } from "./acp/assistant-manager.js";
import { AcpSessionManager } from "./acp/session-manager.js"; import { defaultProposalFile, defaultStateFile, loadConfig, type AppConfig } from "./config.js";
import { defaultStateFile, loadConfig, type AppConfig } from "./config.js";
import type { PlatformAdapter } from "./core/adapter.js"; import type { PlatformAdapter } from "./core/adapter.js";
import { DurableSessionStore } from "./core/durable-session-store.js"; import { DurableSessionStore } from "./core/durable-session-store.js";
import { Gateway } from "./core/gateway.js"; import { Gateway } from "./core/gateway.js";
import { PlatformRegistry } from "./core/platform-registry.js"; import { ProposalStore } from "./core/proposal-store.js";
import { FeishuAdapter } from "./platforms/feishu/adapter.js"; import { FeishuAdapter } from "./platforms/feishu/adapter.js";
import { QqAdapter } from "./platforms/qq/adapter.js"; import { QqAdapter } from "./platforms/qq/adapter.js";
import { QqGatewayClient } from "./platforms/qq/gateway-client.js"; import { QqGatewayClient } from "./platforms/qq/gateway-client.js";
import { WeComAdapter } from "./platforms/wecom/adapter.js"; import { WeComAdapter } from "./platforms/wecom/adapter.js";
import { GenericWebhookAdapter } from "./platforms/webhook/adapter.js"; import { GenericWebhookAdapter } from "./platforms/webhook/adapter.js";
import { WeixinAdapter } from "./platforms/weixin/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 { export interface GatewayRuntime {
app: express.Express; app: express.Express;
gateway: Gateway; gateway: Gateway;
sessionManager: AcpSessionManager; assistantManager: AssistantManager;
store: DurableSessionStore; store: DurableSessionStore;
proposals: ProposalStore;
platformAdapter: PlatformAdapter;
qqGatewayClient?: QqGatewayClient; qqGatewayClient?: QqGatewayClient;
shutdown(): Promise<void>; shutdown(): Promise<void>;
} }
export async function createGatewayRuntime(config: AppConfig): Promise<GatewayRuntime> { export async function createGatewayRuntime(config: AppConfig): Promise<GatewayRuntime> {
const store = new DurableSessionStore(defaultStateFile(config)); const platformType = config.gateway.platform.type;
const identity = { botId: config.bot.id, platform: platformType };
const store = new DurableSessionStore(defaultStateFile(config), identity);
const proposals = new ProposalStore(defaultProposalFile(config), identity);
let assistantManager: AssistantManager | undefined;
await store.open(); await store.open();
const roles = new RoleRegistry(config); try {
const sessionManager = new AcpSessionManager(config.acp, new AcpBackendRegistry(config.backends), roles, store); await proposals.open();
const gateway = new Gateway(config.policy, sessionManager, roles); } catch (error) {
const platforms = new PlatformRegistry(); await store.close().catch(() => undefined);
const qqAdapter = new QqAdapter(config.platforms.qq, gateway); throw error;
const adapters: PlatformAdapter[] = [ }
new FeishuAdapter(config.platforms.feishu, gateway), new WeComAdapter(config.platforms.wecom), qqAdapter, try {
new GenericWebhookAdapter(config.platforms.webhook, gateway), new WeixinAdapter(config.platforms.weixin, gateway) const bot = new BotProfileResolver(config).bot;
]; assistantManager = new AssistantManager(config.runtime.acp, bot, store, proposals, {
for (const adapter of adapters) platforms.register(adapter); maxAssistantSessions: config.runtime.acp.maxAssistantSessions
});
await assistantManager.initialize();
const manager = assistantManager;
const gateway = new Gateway(config.gateway.policy, manager);
manager.setEventSink((chatKey, text, images) => gateway.sendEvent(chatKey, text, images));
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(); const app = express();
app.use(express.json({ limit: "1mb", verify: (req, _res, buf) => { app.use(express.json({ limit: "1mb", verify: (req, _res, buf) => {
(req as express.Request & { rawBody?: Buffer }).rawBody = Buffer.from(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("/health", (_req, res) => res.json({
app.get("/platforms", (_req, res) => res.json({ ok: true, platforms: platforms.list(), roles: roles.list(), backends: config.backends.map(({ id }) => id) })); 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 { const routeName = platformType === "webhook" ? "generic" : platformType;
app.post(`/webhook/${routeName}`, async (req, res) => { const needsWebhook = config.gateway.platform.type !== "qq" || config.gateway.platform.connectionMode === "webhook";
if (needsWebhook) app.post(`/webhook/${routeName}`, async (req, res) => {
try { try {
const response = await platforms.get(adapterName).handleWebhook({ const response = await platformAdapter.handleWebhook({
req, body: req.body, headers: req.headers, query: req.query, req, body: req.body, headers: req.headers, query: req.query,
rawBody: (req as express.Request & { rawBody?: Buffer }).rawBody rawBody: (req as express.Request & { rawBody?: Buffer }).rawBody
}); });
if (response.headers) for (const [key, value] of Object.entries(response.headers)) res.setHeader(key, value); 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 }); res.status(response.status || 200).json(response.body ?? { ok: true });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); console.error(`Webhook ${routeName} failed (${error instanceof Error ? error.name : "unknown error"})`);
console.error(`Webhook ${routeName} failed`, error); res.status(500).json({ ok: false, error: "Internal webhook error" });
res.status(500).json({ ok: false, error: message });
} }
}); });
}
for (const name of ["feishu", "wecom", "qq", "weixin"]) mountWebhook(name);
mountWebhook("generic", "webhook");
const qqGatewayClient = config.platforms.qq.enabled && config.platforms.qq.connectionMode === "websocket"
? new QqGatewayClient(config.platforms.qq, qqAdapter) : undefined;
let closing: Promise<void> | undefined; let closing: Promise<void> | undefined;
const runtime: GatewayRuntime = { return {
app, gateway, sessionManager, store, qqGatewayClient, app, gateway, assistantManager: manager, store, proposals, platformAdapter, qqGatewayClient,
shutdown: () => closing ||= (async () => { shutdown: () => closing ||= (async () => {
qqGatewayClient?.stop(); qqGatewayClient?.stop();
await sessionManager.shutdown(); gateway.shutdown();
await store.close(); let shutdownError: unknown;
try { await manager.shutdown(); } catch (error) { shutdownError = error; }
try { await proposals.close(); } catch (error) { shutdownError ||= error; }
try { await store.close(); } catch (error) { shutdownError ||= error; }
if (shutdownError) throw shutdownError;
})() })()
}; };
return runtime; } catch (error) {
await assistantManager?.shutdown().catch(() => undefined);
await proposals.close().catch(() => undefined);
await store.close().catch(() => undefined);
throw error;
}
} }
export async function createApp(config: AppConfig): Promise<express.Express> { return (await createGatewayRuntime(config)).app; } function createPlatformAdapter(config: AppConfig, gateway: Gateway): PlatformAdapter {
const platform = config.gateway.platform;
switch (platform.type) {
case "feishu": return new FeishuAdapter(platform, gateway);
case "wecom": return new WeComAdapter(platform);
case "qq": return new QqAdapter(platform, gateway);
case "webhook": return new GenericWebhookAdapter(platform, gateway);
case "weixin": return new WeixinAdapter(platform, gateway);
}
}
export interface RunningServer { server: Server; runtime: GatewayRuntime; shutdown(): Promise<void> } export interface RunningServer { server: Server; runtime: GatewayRuntime; shutdown(): Promise<void> }
export async function startServer(config: AppConfig): Promise<RunningServer> { export async function startServer(config: AppConfig): Promise<RunningServer> {
const runtime = await createGatewayRuntime(config); const runtime = await createGatewayRuntime(config);
const server = runtime.app.listen(config.server.port, config.server.host, () => { const server = runtime.app.listen(config.gateway.server.port, config.gateway.server.host);
console.log(`gori-agent listening on ${config.server.host}:${config.server.port}`); try {
if (runtime.qqGatewayClient) { console.log("QQ websocket gateway enabled; connecting to QQ..."); runtime.qqGatewayClient.start(); } await new Promise<void>((resolve, reject) => {
server.once("listening", resolve);
server.once("error", reject);
}); });
console.log(`gori-agent '${config.bot.id}' listening on ${config.gateway.server.host}:${config.gateway.server.port}`);
if (runtime.qqGatewayClient) {
console.log("QQ websocket gateway enabled; connecting to QQ...");
runtime.qqGatewayClient.start();
}
} catch (error) {
if (server.listening) await closeServer(server).catch(() => undefined);
await runtime.shutdown().catch(() => undefined);
throw error;
}
let closing: Promise<void> | undefined; let closing: Promise<void> | undefined;
return { server, runtime, shutdown: () => closing ||= (async () => { return { server, runtime, shutdown: () => closing ||= (async () => {
runtime.qqGatewayClient?.stop(); runtime.qqGatewayClient?.stop();
const runtimeShutdown = runtime.shutdown(); const results = await Promise.allSettled([closeServer(server), runtime.shutdown()]);
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); const failed = results.find((result): result is PromiseRejectedResult => result.status === "rejected");
await runtimeShutdown; if (failed) throw failed.reason;
})() }; })() };
} }
function closeServer(server: Server): Promise<void> {
if (!server.listening) return Promise.resolve();
return new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
void startServer(loadConfig()).catch((error) => { console.error(error); process.exitCode = 1; }); void startServer(loadConfig()).catch((error) => { console.error(error); process.exitCode = 1; });
} }
+74 -7
View File
@@ -1,11 +1,16 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test"; import test from "node:test";
import type { RequestPermissionRequest } from "@agentclientprotocol/sdk"; import type { RequestPermissionRequest } from "@agentclientprotocol/sdk";
import { decidePermission } from "../src/acp/client.js"; import { AcpClient, decidePermission } from "../src/acp/client.js";
const request = (rawInput: unknown): RequestPermissionRequest => ({ const request = (rawInput: unknown, overrides: Partial<RequestPermissionRequest["toolCall"]> = {}): RequestPermissionRequest => ({
sessionId: "session", 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: [ options: [
{ optionId: "allow", name: "Allow", kind: "allow_once" }, { optionId: "allow", name: "Allow", kind: "allow_once" },
{ optionId: "reject", name: "Reject", kind: "reject_once" } { optionId: "reject", name: "Reject", kind: "reject_once" }
@@ -13,11 +18,73 @@ const request = (rawInput: unknown): RequestPermissionRequest => ({
}); });
test("permission deny and allowlist fail closed", () => { test("permission deny and allowlist fail closed", () => {
assert.equal(decidePermission(request("git status"), { permissionMode: "deny", allowedTools: [], allowedCommandPatterns: [] }).outcome.outcome, "selected"); assert.equal(decidePermission(request("git status"), { mode: "deny", allowedTools: [], allowedCommandPatterns: [] }).outcome.outcome, "selected");
const allowed = decidePermission(request("git status"), { permissionMode: "allowlist", allowedTools: ["bash"], allowedCommandPatterns: ["^git status$"] }); const allowed = decidePermission(request("git status"), { mode: "allowlist", allowedTools: ["bash"], allowedCommandPatterns: ["^git status$"] });
assert.deepEqual(allowed.outcome, { outcome: "selected", optionId: "allow" }); 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" }); 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" }); 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" });
});
test("reports activity for every update from the active session", async () => {
const child = spawn(process.execPath, [path.resolve("test/fixtures/fake-acp-agent.mjs")], { stdio: ["pipe", "pipe", "pipe"] });
let activities = 0;
const client = new AcpClient(child, {
initializeTimeoutMs: 1_000,
policy: { mode: "deny", allowedTools: [], allowedCommandPatterns: [] },
onSessionActivity: () => { activities++; }
});
try {
await client.initialize();
await client.newSession(path.resolve("."));
assert.equal(await client.prompt("activity"), "firstsecond");
assert.equal(activities, 2);
} finally {
client.close();
child.kill("SIGTERM");
}
});
test("prompt sends text and image content blocks and records the image capability", async () => {
const logFile = path.join(os.tmpdir(), `gori-acp-client-${crypto.randomUUID()}.log`);
const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
const child = spawn(process.execPath, [fixture], {
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, FAKE_ACP_LOG: logFile }
});
const client = new AcpClient(child, {
initializeTimeoutMs: 1_000,
policy: { mode: "deny", allowedTools: [], allowedCommandPatterns: [] }
});
try {
await client.initialize();
assert.equal(client.supportsImageInput(), true);
await client.newSession(path.resolve("."));
const image = Buffer.from("fake-png").toString("base64");
const reply = await client.prompt([
{ type: "image", data: image, mimeType: "image/png" },
{ type: "text", text: "hello blocks" }
]);
assert.match(reply, /^reply:.+:hello blocks$/);
const prompts = fs.readFileSync(logFile, "utf8").trim().split("\n").filter(Boolean)
.map((line) => JSON.parse(line) as { method: string; text?: string; images?: number })
.filter((entry) => entry.method === "session/prompt");
assert.equal(prompts.length, 1);
assert.equal(prompts[0]!.text, "hello blocks");
assert.equal(prompts[0]!.images, 1);
} finally {
client.close();
child.kill("SIGTERM");
await fs.promises.rm(logFile, { force: true });
}
}); });
-58
View File
@@ -1,58 +0,0 @@
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 { 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";
const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
function config(stateFile: string, logFile: string) {
return parseConfig({
configVersion: 2, acp: { stateFile, promptTimeoutMs: 2_000, cancelGraceMs: 100, idleTimeoutMs: 100, sweepIntervalMs: 20, maxProcesses: 2 },
backends: [{ id: "kimi", command: process.execPath, args: [fixture], env: { FAKE_ACP_LOG: logFile } }],
skills: [], defaultRole: "assistant",
roles: [{ id: "assistant", backend: "kimi", workspace: path.resolve("."), persona: "test", skills: [], policy: { permissionMode: "deny" } }],
platforms: {}
});
}
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) };
}
test("creates, persists, idles, and resumes the same native session", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-acp-")); const state = path.join(dir, "state.json"); const log = path.join(dir, "fake.log");
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-/);
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();
const second = await runtime(state, log);
const resumed = await second.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "three" });
assert.equal(resumed.text, `reply:${sessionId}:three`);
const entries = (await fs.promises.readFile(log, "utf8")).trim().split("\n").map(JSON.parse);
assert.ok(entries.some((entry) => entry.method === "session/resume" && entry.sessionId === sessionId));
await second.manager.shutdown(); await second.store.close();
});
test("cancel reaches a hanging ACP prompt and new unbinds", 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" });
const hanging = current.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "hang" });
await new Promise((resolve) => setTimeout(resolve, 50));
assert.equal(await current.manager.cancel("qq", "chat"), true);
await hanging;
await current.manager.reset("qq", "chat");
assert.equal(current.store.stats().bindings, 0);
await current.manager.shutdown(); await current.store.close();
});
+52 -9
View File
@@ -1,23 +1,23 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path"; import path from "node:path";
import test from "node:test"; import test from "node:test";
import { AcpWorker } from "../src/acp/worker.js"; import { AcpWorker } from "../src/acp/worker.js";
import { parseConfig } from "../src/config.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"); const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
function makeWorker(promptTimeoutMs = 80) { function makeWorker(promptTimeoutMs = 80, env: Record<string, string> = {}) {
const config = parseConfig({ const config = parseConfig({
configVersion: 2, configVersion: 3,
acp: { promptTimeoutMs, cancelGraceMs: 30 }, bot: { id: "test-bot", workspace: path.resolve("."), persona: "", agent: { id: "fake", command: process.execPath, args: [fixture], env }, permissions: { mode: "deny" } },
backends: [{ id: "kimi", command: process.execPath, args: [fixture] }], gateway: { platform: { type: "webhook", secret: "secret" } },
defaultRole: "assistant", runtime: { acp: { promptTimeoutMs, cancelGraceMs: 30 } }
roles: [{ id: "assistant", backend: "kimi", workspace: path.resolve("."), policy: { permissionMode: "deny" } }],
platforms: {}
}); });
let crashes = 0; 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 }; return { worker, crashes: () => crashes };
} }
@@ -35,3 +35,46 @@ test("worker reports an unexpected ACP subprocess exit", async () => {
await new Promise((resolve) => setTimeout(resolve, 20)); await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(current.crashes(), 1); assert.equal(current.crashes(), 1);
}); });
test("immediate termination kills ACP descendants in the worker process group", async (t) => {
await assertDescendantTermination(t, (worker) => { worker.terminateImmediately(); });
});
test("normal termination SIGKILLs descendants that ignore SIGTERM", async (t) => {
await assertDescendantTermination(t, (worker) => worker.terminate());
});
test("persisted worker token safely identifies and kills an orphaned process group", async () => {
const { worker } = makeWorker(2_000);
await worker.start();
assert.ok(worker.processGroup);
await AcpWorker.terminatePersistedGroup(worker.processGroup, 100);
assert.equal(processGroupExists(worker.processGroup.pgid), false);
});
async function assertDescendantTermination(t: test.TestContext, terminate: (worker: AcpWorker) => void | Promise<void>): Promise<void> {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-acp-group-"));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const pidFile = path.join(directory, "descendant.pid");
const { worker } = makeWorker(2_000, { FAKE_ACP_DESCENDANT_PID_FILE: pidFile });
await worker.start();
const prompt = worker.prompt("spawn descendant");
const promptRejected = assert.rejects(prompt);
for (let count = 0; count < 100 && !fs.existsSync(pidFile); count++) await new Promise((resolve) => setTimeout(resolve, 10));
const pid = Number(fs.readFileSync(pidFile, "utf8").trim());
assert.equal(processExists(pid), true);
await terminate(worker);
await promptRejected;
for (let count = 0; count < 100 && processExists(pid); count++) await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(processExists(pid), false);
}
function processExists(pid: number): boolean {
try { process.kill(pid, 0); return true; }
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
}
function processGroupExists(pgid: number): boolean {
try { process.kill(-pgid, 0); return true; }
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
}
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
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, unsafe ACP args, and every credential family", () => {
assert.doesNotThrow(() => assertOperationalConfig(config));
const unsafeArgs = parseConfig({ ...config, bot: { ...config.bot, agent: { ...config.bot.agent, args: ["--yolo", "acp"] } } });
assert.throws(() => assertOperationalConfig(unsafeArgs), /exactly \['acp'\]/);
const platforms = [
{ type: "qq", appId: "QQ_APP_ID", clientSecret: "QQ_CLIENT_SECRET", botNames: ["QQ_BOT_NAME"] },
{ type: "feishu", appId: "FEISHU_APP_ID", appSecret: "FEISHU_APP_SECRET" },
{ type: "wecom", corpId: "WECOM_CORP_ID", agentId: "WECOM_AGENT_ID", secret: "WECOM_SECRET" },
{ type: "webhook", secret: "WEBHOOK_SECRET" },
{ type: "weixin", secret: "WEIXIN_SECRET" }
];
for (const platform of platforms) {
const candidate = parseConfig({ ...config, gateway: { ...config.gateway, platform } });
assert.throws(() => assertOperationalConfig(candidate), /missing or placeholder/);
}
});
+59
View File
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
function runCli(args: string[], root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-cli-"))) {
return spawnSync(process.execPath, ["--import", "tsx", path.resolve("src/cli.ts"), ...args], {
cwd: path.resolve("."),
encoding: "utf8",
env: { ...process.env, GORI_AGENT_ROOT: root, GORI_GATEWAY_CONFIG: "" }
});
}
test("CLI help exposes only direct instance commands", () => {
const result = runCli(["--help"]);
assert.equal(result.status, 0);
for (const command of ["init", "setup", "start", "stop", "restart", "status", "logs", "doctor"]) {
assert.match(result.stdout, new RegExp(`gori-agent ${command} <bot-id>`));
}
assert.match(result.stdout, /gori-agent list/);
assert.doesNotMatch(result.stdout, /gori-agent instance|--config|--json|discover-backends|print feishu/);
});
test("CLI dispatches direct commands and rejects removed entry points", () => {
assert.equal(runCli(["list"]).status, 0);
for (const command of ["setup", "start", "stop", "restart", "status", "logs", "doctor"]) {
const result = runCli([command, "missing-bot"]);
assert.equal(result.status, 1);
assert.match(result.stderr, /Runtime config does not exist/);
}
const init = runCli(["init", "INVALID"]);
assert.equal(init.status, 1);
assert.match(init.stderr, /Invalid bot ID/);
for (const removed of [["instance", "list"], ["discover-backends"], ["discover-agents"], ["print", "feishu"]]) {
const result = runCli(removed);
assert.equal(result.status, 1);
assert.match(result.stderr, /Unknown command/);
}
});
test("CLI rejects options and invalid command argument counts", () => {
for (const args of [["status", "bot", "--config", "config.json"], ["list", "--json"], ["start", "--bogus"]]) {
const result = runCli(args);
assert.equal(result.status, 1);
assert.match(result.stderr, /Unknown option/);
}
const missing = runCli(["setup"]);
assert.equal(missing.status, 1);
assert.match(missing.stderr, /setup requires exactly one <bot-id>/);
const extra = runCli(["doctor", "one", "two"]);
assert.equal(extra.status, 1);
assert.match(extra.stderr, /doctor requires exactly one <bot-id>/);
});
+63 -20
View File
@@ -1,31 +1,74 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test"; import test from "node:test";
import { parseConfig } from "../src/config.js"; import { parseConfig } from "../src/config.js";
const platforms = { function raw(overrides: Record<string, unknown> = {}) {
qq: { enabled: true, appId: "id", clientSecret: "secret", connectionMode: "websocket" as const }, return {
feishu: {}, wecom: {}, webhook: {}, weixin: {} 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", () => { test("parses Config v3 defaults for one Bot, agent, and platform", () => {
const config = parseConfig({ const config = parseConfig(raw());
server: { port: 8787 }, policy: {}, defaultAgent: "kimi", assert.equal(config.configVersion, 3);
agents: [{ name: "kimi", command: "/home/ubuntu/.kimi-code/bin/kimi", args: ["-p"], cwd: "/tmp" }], platforms assert.equal(config.bot.id, "test-bot");
}); assert.equal(config.bot.agent.id, "kimi");
assert.equal(config.configVersion, 2); assert.equal(config.gateway.platform.type, "webhook");
assert.deepEqual(config.backends[0].args, ["acp"]); assert.equal(config.runtime.acp.promptTimeoutMs, 14_400_000);
assert.equal(config.roles[0].workspace, "/tmp"); assert.equal(config.runtime.acp.maxAssistantSessions, 4);
assert.equal(config.platforms.qq.clientSecret, "secret"); assert.equal(config.bot.permissions.mode, "deny");
assert.equal(config.platforms.qq.connectionMode, "websocket"); assert.equal(config.bot.assistantPersona, "");
}); });
test("does not silently migrate a non-Kimi CLI agent", () => { test("parses optional bot.assistantPersona", () => {
assert.throws(() => parseConfig({ defaultAgent: "echo", agents: [{ name: "echo", command: "node" }], platforms }), /only supports a Kimi/); const config = parseConfig(raw({ bot: { ...(raw().bot as object), assistantPersona: "像运维老同事一样讲话" } }));
assert.equal(config.bot.assistantPersona, "像运维老同事一样讲话");
}); });
test("validates role references and absolute workspace", () => { test("explicitly rejects non-v3 configuration and unknown fields", () => {
assert.throws(() => parseConfig({ assert.throws(() => parseConfig({ configVersion: 2 }), /requires Config v3/);
configVersion: 2, backends: [{ id: "kimi", command: "kimi", args: ["acp"] }], defaultRole: "a", assert.throws(() => parseConfig({ defaultAgent: "kimi" }), /requires Config v3/);
roles: [{ id: "a", backend: "missing", workspace: "relative" }], platforms assert.throws(() => parseConfig(raw({ bot: { ...(raw().bot as object), legacyRole: "ops" } })), /Unrecognized key/);
}), /workspace must be absolute/); assert.throws(() => parseConfig(raw({ gateway: { platform: { type: "webhook", secret: "secret", enabled: true } } })), /Unrecognized key/);
});
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, 14_400_000);
assert.equal(config.gateway.platform.type, "qq");
assert.match(text, /QQ_CLIENT_SECRET/);
assert.doesNotMatch(text, /configVersion"\s*:\s*[12]/);
}); });
+67 -19
View File
@@ -3,39 +3,87 @@ import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import test from "node:test"; import test from "node:test";
import { DurableSessionStore } from "../src/core/durable-session-store.js"; import { DurableSessionStore, chatKeyFor } from "../src/core/durable-session-store.js";
test("persists chat role and native session across reopen", async () => { const identity = { botId: "test-bot", platform: "qq" };
const binding = { chatKey: "qq:chat", agentId: "kimi", nativeSessionId: "native-1", assistantWorkspace: "/tmp/assistant", botFingerprint: "fp", createdAt: 1, updatedAt: 1 };
test("persists state v3 assistant binding across reopen with 0600 atomic file", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-store-")); const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-store-"));
const file = path.join(dir, "state.json"); const file = path.join(dir, "state.json");
const first = new DurableSessionStore(file); const first = new DurableSessionStore(file, identity);
await first.open(); await first.open();
await first.setSelectedRole("qq:chat", "ops"); await first.setBinding(binding);
await first.setBinding({ chatKey: "qq:chat", roleId: "ops", backendId: "kimi", nativeSessionId: "native-1", workspace: "/tmp", roleFingerprint: "fp", createdAt: 1, updatedAt: 1 });
await first.close(); await first.close();
assert.equal((await fs.promises.readdir(dir)).some((name) => name.endsWith(".tmp")), false); assert.equal((await fs.promises.readdir(dir)).some((name) => name.endsWith(".tmp")), false);
assert.equal((await fs.promises.stat(file)).mode & 0o777, 0o600);
const text = await fs.promises.readFile(file, "utf8");
const persisted = JSON.parse(text);
assert.equal(persisted.version, 3); assert.equal(persisted.botId, "test-bot"); assert.equal(persisted.platform, "qq");
assert.deepEqual(persisted.bindings["qq:chat"], binding);
assert.equal("mainTask" in persisted, false);
assert.doesNotMatch(text, /message|prompt|summary|content|lease/i);
const second = new DurableSessionStore(file); const second = new DurableSessionStore(file, identity);
await second.open(); await second.open();
assert.equal(second.getSelectedRole("qq:chat", "assistant"), "ops"); assert.deepEqual(second.getBinding("qq:chat"), binding);
assert.equal(second.getBinding("qq:chat", "ops")?.nativeSessionId, "native-1"); assert.equal(second.stats().bindings, 1);
await second.touchBinding("qq:chat");
const touched = second.getBinding("qq:chat");
assert.ok((touched?.updatedAt ?? 0) >= 1);
assert.deepEqual(await second.deleteBinding("qq:chat"), touched);
assert.equal(second.getBinding("qq:chat"), undefined);
await second.close(); await second.close();
}); });
test("preserves corrupt state and fails explicitly", async () => { test("rejects state v1/v2 and identity mismatch without rewriting the original file", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-store-bad-")); const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-store-id-"));
const file = path.join(dir, "state.json"); const file = path.join(dir, "state.json");
await fs.promises.writeFile(file, "not-json"); const v1 = '{"version":1,"bindings":{}}\n';
const store = new DurableSessionStore(file); await fs.promises.writeFile(file, v1);
await assert.rejects(store.open(), /original file was preserved/); await assert.rejects(new DurableSessionStore(file, identity).open(), /requires state v3/);
assert.equal(await fs.promises.readFile(file, "utf8"), "not-json"); assert.equal(await fs.promises.readFile(file, "utf8"), v1);
const v2 = JSON.stringify({ version: 2, botId: "test-bot", platform: "qq", bindings: {}, mainTask: { ownerChatKey: "qq:chat", state: "running", workspace: "/tmp", botFingerprint: "fp", startedAt: 1, updatedAt: 1 } });
await fs.promises.writeFile(file, v2);
await assert.rejects(new DurableSessionStore(file, identity).open(), /requires state v3/);
assert.equal(await fs.promises.readFile(file, "utf8"), v2);
await fs.promises.writeFile(file, JSON.stringify({ version: 3, botId: "other", platform: "qq", bindings: {} }));
await assert.rejects(new DurableSessionStore(file, identity).open(), /identity mismatch/);
await fs.promises.writeFile(file, JSON.stringify({ version: 3, botId: "test-bot", platform: "feishu", bindings: {} }));
await assert.rejects(new DurableSessionStore(file, identity).open(), /identity mismatch/);
await fs.promises.writeFile(file, JSON.stringify({ version: 3, botId: "test-bot", platform: "qq", bindings: { "qq:chat": { chatKey: "qq:other" } } }));
await assert.rejects(new DurableSessionStore(file, identity).open(), /invalid state v3 binding/);
const legacyField = { ...binding, workspace: "/tmp" } as Record<string, unknown>;
delete legacyField.assistantWorkspace;
await fs.promises.writeFile(file, JSON.stringify({ version: 3, botId: "test-bot", platform: "qq", bindings: { "qq:chat": legacyField } }));
await assert.rejects(new DurableSessionStore(file, identity).open(), /invalid state v3 binding/);
}); });
test("refuses a second writer lock", async () => { test("preserves corrupt state and refuses a second writer lock", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-store-lock-")); 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 file = path.join(dir, "state.json");
const first = new DurableSessionStore(file); await first.open(); const first = new DurableSessionStore(file, identity); await first.open();
const second = new DurableSessionStore(file); await assert.rejects(new DurableSessionStore(file, identity).open(), /locked by another/);
await assert.rejects(second.open(), /locked by another/);
await first.close(); 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);
});
test("chatKeyFor builds platform-qualified keys", () => {
assert.equal(chatKeyFor("qq", "group:abc"), "qq:group:abc");
assert.equal(chatKeyFor("feishu", "oc_1"), "feishu:oc_1");
}); });
+169 -18
View File
@@ -1,47 +1,126 @@
#!/usr/bin/env node #!/usr/bin/env node
import { spawn } from "node:child_process";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path";
import { Readable, Writable } from "node:stream"; import { Readable, Writable } from "node:stream";
import * as acp from "@agentclientprotocol/sdk"; import * as acp from "@agentclientprotocol/sdk";
const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const pending = new Map(); const pending = new Map();
const logFile = process.env.FAKE_ACP_LOG; const logFile = process.env.FAKE_ACP_LOG;
const log = (entry) => { if (logFile) fs.appendFileSync(logFile, `${JSON.stringify(entry)}\n`); }; const log = (entry) => { if (logFile) fs.appendFileSync(logFile, `${JSON.stringify(entry)}\n`); };
const assistantEnvelope = (reply, actions) => `${reply}\n<GORI_ASSISTANT_ACTION_V2>${JSON.stringify({ reply, actions })}</GORI_ASSISTANT_ACTION_V2>`;
const workerEnvelope = (result) => `worker reply\n<GORI_WORKER_RESULT_V2>${JSON.stringify(result)}</GORI_WORKER_RESULT_V2>`;
const workerEnvelopeTruncated = (result) => `worker reply\n<GORI_WORKER_RESULT_V2>${JSON.stringify(result)}`;
const app = acp.agent({ name: "fake-acp-agent" }) const app = acp.agent({ name: "fake-acp-agent" })
.onRequest(acp.methods.agent.initialize, ({ params }) => { .onRequest(acp.methods.agent.initialize, ({ params }) => {
log({ method: "initialize" }); log({ method: "initialize" });
return { return {
protocolVersion: params.protocolVersion, protocolVersion: params.protocolVersion,
agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {}, close: {}, list: {} } }, agentCapabilities: {
loadSession: true,
promptCapabilities: { image: process.env.FAKE_ACP_IMAGE_CAP !== "0" },
sessionCapabilities: { resume: {}, close: {}, list: {} }
},
agentInfo: { name: "fake-acp-agent", version: "1" } agentInfo: { name: "fake-acp-agent", version: "1" }
}; };
}) })
.onRequest(acp.methods.agent.session.new, ({ params }) => { .onRequest(acp.methods.agent.session.new, async ({ params }) => {
const sessionId = `fake-${Date.now()}-${Math.random().toString(16).slice(2)}`; const sessionId = `fake-${Date.now()}-${Math.random().toString(16).slice(2)}`;
log({ method: "session/new", sessionId, cwd: params.cwd }); log({ method: "session/new", sessionId, cwd: params.cwd });
if (process.env.FAKE_ACP_NEW_HANG === "1") await new Promise(() => undefined);
return { sessionId }; return { sessionId };
}) })
.onRequest(acp.methods.agent.session.load, ({ params }) => { log({ method: "session/load", sessionId: params.sessionId }); return {}; }) .onRequest(acp.methods.agent.session.load, ({ params }) => {
.onRequest(acp.methods.agent.session.resume, ({ params }) => { log({ method: "session/resume", sessionId: params.sessionId }); return {}; }) 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.close, ({ params }) => { log({ method: "session/close", sessionId: params.sessionId }); return {}; })
.onRequest(acp.methods.agent.session.list, () => ({ sessions: [] })) .onRequest(acp.methods.agent.session.list, () => ({ sessions: [] }))
.onRequest(acp.methods.agent.session.prompt, async ({ params, client, signal }) => { .onRequest(acp.methods.agent.session.prompt, async ({ params, client, signal }) => {
const text = params.prompt.filter((item) => item.type === "text").map((item) => item.text).join(""); const text = params.prompt.filter((item) => item.type === "text").map((item) => item.text).join("");
log({ method: "session/prompt", sessionId: params.sessionId, text }); const images = params.prompt.filter((item) => item.type === "image").length;
if (text === "crash") process.exit(9); log({ method: "session/prompt", sessionId: params.sessionId, text, images });
if (text === "permission") { if (text === "crash" || text.startsWith("crash\n\nWhen this turn")) process.exit(9);
const response = await client.request(acp.methods.client.session.requestPermission, { if (text.includes("Initialize this ACP session")) {
sessionId: params.sessionId, if (process.env.FAKE_ACP_BOOTSTRAP_DELAY_MS) await new Promise((resolve) => setTimeout(resolve, Number(process.env.FAKE_ACP_BOOTSTRAP_DELAY_MS)));
toolCall: { toolCallId: "bash-1", title: "bash git status", kind: "execute", name: "bash", rawInput: "git status" }, await update(client, params.sessionId, "READY");
options: [
{ optionId: "allow", name: "Allow", kind: "allow_once" },
{ optionId: "reject", name: "Reject", kind: "reject_once" }
]
});
await update(client, params.sessionId, response.outcome.outcome === "selected" ? response.outcome.optionId : "cancelled");
return { stopReason: "end_turn" }; return { stopReason: "end_turn" };
} }
if (text === "hang") {
// Assistant protocol
if (text.startsWith("Your previous response did not end with a valid GORI_ASSISTANT_ACTION_V2")) {
await update(client, params.sessionId, assistantEnvelope("repaired", []));
return { stopReason: "end_turn" };
}
if (text.startsWith("Your previous response did not end with a valid GORI_WORKER_RESULT_V2")) {
await update(client, params.sessionId, process.env.FAKE_ACP_INVALID_REPAIR === "1"
? "still invalid"
: workerEnvelope({ status: "PENDING", summary: "repaired" }));
return { stopReason: "end_turn" };
}
if (text.includes("[Internal event")) {
await update(client, params.sessionId, assistantEnvelope("event received: worker update", []));
return { stopReason: "end_turn" };
}
if (text.includes("[User message]")) {
const userText = (text.split("[User message]\n")[1] || "").split("\n\n")[0].trim();
if (userText === "force tool") {
await client.notify(acp.methods.client.session.update, {
sessionId: params.sessionId,
update: { sessionUpdate: "tool_call", toolCallId: "read-1", title: "Read a file", kind: "read", status: "in_progress" }
});
return { stopReason: "end_turn" };
}
let response;
if (userText.startsWith("create proposal:")) {
const goal = userText.slice("create proposal:".length).trim();
response = assistantEnvelope("proposal drafted", [{ type: "create_proposal", title: "Test proposal", goal, steps: ["step 1"] }]);
} else if (userText === "confirm") response = assistantEnvelope("confirmed", [{ type: "confirm" }]);
else if (userText === "confirm and start next") response = assistantEnvelope("confirmed", [{ type: "confirm" }, { type: "start_next" }]);
else if (userText === "finish") response = assistantEnvelope("finishing", [{ type: "finish" }]);
else if (userText.startsWith("follow up:")) response = assistantEnvelope("following up", [{ type: "follow_up", instruction: userText.slice("follow up:".length).trim() }]);
else if (userText.startsWith("adjust proposal:")) response = assistantEnvelope("adjusting", [{ type: "adjust_proposal", title: userText.slice("adjust proposal:".length).trim() }]);
else if (userText.startsWith("send image:")) response = assistantEnvelope("sending image", [{ type: "send_image", path: userText.slice("send image:".length).trim() }]);
else if (userText === "start next") response = assistantEnvelope("starting next", [{ type: "start_next" }]);
else if (userText === "stop") response = assistantEnvelope("stopping", [{ type: "stop" }]);
else if (userText === "cancel proposal") response = assistantEnvelope("cancelling", [{ type: "cancel" }]);
else if (userText === "ack pending") response = assistantEnvelope("任务「Test proposal」还在等你确认。", []);
else if (userText === "truncated assistant") response = `ok\n<GORI_ASSISTANT_ACTION_V2>${JSON.stringify({ reply: "ok", actions: [] })}`;
else if (userText === "invalid assistant") response = "invalid without envelope";
else response = assistantEnvelope("ok", []);
await update(client, params.sessionId, response);
return { stopReason: "end_turn" };
}
// Worker protocol
if (text.startsWith("The user sent a follow-up instruction")) {
await update(client, params.sessionId, workerEnvelope({ status: "PENDING", summary: `continued ${params.sessionId}` }));
return { stopReason: "end_turn" };
}
if (text.startsWith("Execute this confirmed proposal")) {
const goal = ((text.split("Goal: ")[1] || "").split("\n")[0] || "").trim();
if (goal.includes("toolhang")) {
await client.notify(acp.methods.client.session.update, {
sessionId: params.sessionId,
update: { sessionUpdate: "tool_call", toolCallId: "read-1", title: "Read package.json", kind: "read", status: "in_progress" }
});
await client.notify(acp.methods.client.session.update, {
sessionId: params.sessionId,
update: { sessionUpdate: "tool_call", toolCallId: "read-2", title: "Read README.md", kind: "read", status: "in_progress" }
});
await client.notify(acp.methods.client.session.update, {
sessionId: params.sessionId,
update: { sessionUpdate: "tool_call", toolCallId: "exec-1", title: "bash npm test", kind: "execute", status: "in_progress" }
});
await new Promise((resolve) => { await new Promise((resolve) => {
const done = () => resolve(undefined); const done = () => resolve(undefined);
pending.set(params.sessionId, done); pending.set(params.sessionId, done);
@@ -50,7 +129,79 @@ const app = acp.agent({ name: "fake-acp-agent" })
pending.delete(params.sessionId); pending.delete(params.sessionId);
return { stopReason: "cancelled" }; return { stopReason: "cancelled" };
} }
await update(client, params.sessionId, text.includes("Initialize this ACP session") ? "READY" : `reply:${params.sessionId}:${text}`); if (goal.includes("hang")) {
await new Promise((resolve) => {
const done = () => resolve(undefined);
pending.set(params.sessionId, done);
signal.addEventListener("abort", done, { once: true });
});
pending.delete(params.sessionId);
return { stopReason: "cancelled" };
}
if (goal.includes("invalid")) {
await update(client, params.sessionId, "invalid without envelope");
return { stopReason: "end_turn" };
}
if (process.env.FAKE_ACP_WORKER_GATE_FILE) {
while (!fs.existsSync(process.env.FAKE_ACP_WORKER_GATE_FILE)) await new Promise((resolve) => setTimeout(resolve, 5));
}
if (goal.includes("truncbad")) {
await update(client, params.sessionId, `worker reply\n<GORI_WORKER_RESULT_V2>{"status":"PENDING","summary":"cut off mid json"`);
return { stopReason: "end_turn" };
}
if (goal.includes("truncattach")) {
const outbox = path.join(process.cwd(), ".gori-outbox");
fs.mkdirSync(outbox, { recursive: true });
fs.writeFileSync(path.join(outbox, "shot.png"), Buffer.concat([PNG_HEADER, Buffer.from("fake-png-payload-truncated")]));
await update(client, params.sessionId, workerEnvelopeTruncated({ status: "PENDING", summary: "made a pic", attachments: [{ path: ".gori-outbox/shot.png", mimeType: "image/png" }] }));
return { stopReason: "end_turn" };
}
if (goal.includes("attachbad")) {
await update(client, params.sessionId, workerEnvelope({ status: "PENDING", summary: "made a pic", attachments: [{ path: "/tmp/evil.png" }, { path: "not-an-image.txt" }] }));
return { stopReason: "end_turn" };
}
if (goal.includes("attach")) {
const outbox = path.join(process.cwd(), ".gori-outbox");
fs.mkdirSync(outbox, { recursive: true });
fs.writeFileSync(path.join(outbox, "shot.png"), Buffer.concat([PNG_HEADER, Buffer.from("fake-png-payload-v1")]));
fs.writeFileSync(path.join(process.cwd(), "not-an-image.txt"), "plain text");
await update(client, params.sessionId, workerEnvelope({ status: "PENDING", summary: "made a pic", attachments: [{ path: ".gori-outbox/shot.png", mimeType: "image/png" }] }));
return { stopReason: "end_turn" };
}
const result = goal.includes("ask")
? { status: "PENDING", summary: "hit a dirty target", question: "May I overwrite it?", workspaceDirty: true }
: goal.includes("fail")
? { status: "PENDING", summary: "something broke" }
: { status: "PENDING", summary: "goal done" };
await update(client, params.sessionId, workerEnvelope(result));
return { stopReason: "end_turn" };
}
// Legacy behaviors used by acp-worker/acp-client tests
const request = text.split("\n\nWhen this turn is finished")[0];
if (request === "spawn descendant") {
const descendant = spawn(process.execPath, ["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)"], { stdio: "ignore" });
if (process.env.FAKE_ACP_DESCENDANT_PID_FILE) fs.writeFileSync(process.env.FAKE_ACP_DESCENDANT_PID_FILE, `${descendant.pid}\n`);
await new Promise((resolve) => signal.addEventListener("abort", resolve, { once: true }));
return { stopReason: "cancelled" };
}
if (request === "hang") {
await new Promise((resolve) => {
const done = () => resolve(undefined);
pending.set(params.sessionId, done);
signal.addEventListener("abort", done, { once: true });
});
pending.delete(params.sessionId);
return { stopReason: "cancelled" };
}
if (request === "activity") {
await update(client, params.sessionId, "first");
await new Promise((resolve) => setTimeout(resolve, 600));
await update(client, params.sessionId, "second");
await new Promise((resolve) => setTimeout(resolve, 700));
return { stopReason: "end_turn" };
}
await update(client, params.sessionId, `reply:${params.sessionId}:${request}`);
return { stopReason: "end_turn" }; return { stopReason: "end_turn" };
}) })
.onNotification(acp.methods.agent.session.cancel, ({ params }) => { .onNotification(acp.methods.agent.session.cancel, ({ params }) => {
+717 -30
View File
@@ -1,45 +1,732 @@
import assert from "node:assert/strict"; 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 test from "node:test";
import type { ConversationRuntime } from "../src/acp/types.js"; import type { ConversationRequest, ConversationRuntime } from "../src/acp/types.js";
import { parseConfig } from "../src/config.js";
import type { PlatformAdapter } from "../src/core/adapter.js"; import type { PlatformAdapter } from "../src/core/adapter.js";
import { Gateway } from "../src/core/gateway.js"; import { Gateway } from "../src/core/gateway.js";
import type { IncomingMessage } from "../src/core/types.js"; import type { Proposal } from "../src/core/proposal-store.js";
import { RoleRegistry } from "../src/roles/role-registry.js"; import type { IncomingMessage, OutgoingMessage } from "../src/core/types.js";
const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
async function tempPng(payload: string): Promise<{ dir: string; file: string }> {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-gateway-img-"));
const file = path.join(dir, "shot.png");
await fs.promises.writeFile(file, Buffer.concat([PNG_HEADER, Buffer.from(payload)]));
return { dir, file };
}
interface PendingTurn {
request: ConversationRequest;
resolve(text: string): void;
reject(error: Error): void;
}
function fakeProposal(overrides: Partial<Proposal> = {}): Proposal {
return {
id: "proposal-1",
title: "修复登录页",
goal: "goal",
steps: ["step"],
ownerChatKey: "qq:chat",
requesterUserId: "user",
status: "proposed",
createdAt: 1,
updatedAt: 1,
...overrides
};
}
class FakeRuntime implements ConversationRuntime { class FakeRuntime implements ConversationRuntime {
role = "assistant"; prompts = 0; cancelled = 0; resets = 0; release?: () => void; readonly turns: PendingTurn[] = [];
async prompt() { this.prompts++; await new Promise<void>((resolve) => { this.release = resolve; }); return { text: "done", roleId: this.role, backendId: "kimi" }; } cancelled = 0;
async cancel() { this.cancelled++; this.release?.(); return true; } confirmed = 0;
async reset() { this.resets++; } finished = 0;
async selectRole(_p: string, _c: string, role: string) { this.role = role; } stopped = 0;
selectedRole() { return this.role; } listed = 0;
status() { return { role: this.role, running: Boolean(this.release) }; } cancelResult = true;
confirmResult = true;
finishResult = true;
stopResult = true;
resetResult = false;
proposals: Proposal[] = [];
commandUsers: Record<string, string | undefined> = {};
async prompt(request: ConversationRequest) {
return new Promise<{ text: string; botId: string; agentId: string }>((resolve, reject) => {
this.turns.push({
request,
resolve: (text) => resolve({ text, botId: "test-bot", agentId: "kimi" }),
reject
});
});
}
async cancel(_platform: string, _chatId: string, userId: string) { this.cancelled++; this.commandUsers.cancel = userId; return this.cancelResult; }
async confirm(_platform: string, _chatId: string, userId: string) { this.confirmed++; this.commandUsers.confirm = userId; return this.confirmResult; }
async finish(_platform: string, _chatId: string, userId: string) { this.finished++; this.commandUsers.finish = userId; return this.finishResult; }
async stop(_platform: string, _chatId: string, userId: string) { this.stopped++; this.commandUsers.stop = userId; return this.stopResult; }
async reset(_platform: string, _chatId: string, userId: string) { this.commandUsers.reset = userId; return this.resetResult; }
listProposals(_platform: string, _chatId: string, userId: string) { this.listed++; this.commandUsers.list = userId; return this.proposals; }
status(_platform?: string, _chatId?: string, userId?: string) {
this.commandUsers.status = userId;
return {
bot: "test-bot", agent: "kimi", workspace: "/tmp",
running: this.turns.length > 0, phase: "processing"
};
}
stats() { return { activeWorkers: 0, inFlight: 0, crashes: 0, persistedBindings: 0 }; } stats() { return { activeWorkers: 0, inFlight: 0, crashes: 0, persistedBindings: 0 }; }
async shutdown() {} async shutdown() {}
} }
const cfg = parseConfig({ configVersion: 2, backends: [{ id: "kimi", command: "kimi", args: ["acp"] }], defaultRole: "assistant", roles: [ const policy = { allowedUsers: [] as string[], allowedChats: [] as string[], requireMentionInGroup: false };
{ id: "assistant", backend: "kimi", workspace: "/tmp" }, { id: "ops", backend: "kimi", workspace: "/tmp" } const message = (text: string, messageId = text, chatId = "chat"): IncomingMessage => ({
], platforms: {} }); platform: "qq", chatId, userId: "user", text, messageId
const adapter: PlatformAdapter = { name: "test", async handleWebhook() { return {}; }, async sendMessage() {} }; });
const message = (text: string): IncomingMessage => ({ platform: "qq", chatId: "chat", userId: "user", text }); const groupMessage = (text: string, messageId: string): IncomingMessage => ({
platform: "qq", chatId: "group:g1", userId: "user", text, messageId, isGroup: true
});
const flush = async () => { await Promise.resolve(); await Promise.resolve(); await new Promise<void>((resolve) => setImmediate(resolve)); };
const waitFor = async (condition: () => boolean) => {
const deadline = Date.now() + 5_000;
while (!condition()) {
if (Date.now() > deadline) break;
await flush();
await new Promise<void>((resolve) => setTimeout(resolve, 5));
}
assert.equal(condition(), true);
};
test("cancel bypasses the chat lock and new resets", async () => { function recordingAdapter(sent: OutgoingMessage[] = [], supportsImages = false): PlatformAdapter {
const runtime = new FakeRuntime(); const gateway = new Gateway(cfg.policy, runtime, new RoleRegistry(cfg)); return { name: "test", supportsImages, async handleWebhook() { return {}; }, async sendMessage(outgoing) { sent.push(outgoing); } };
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 }); // Simulates QQ passive-reply dedup: a repeated msg_id + msg_seq pair is rejected.
assert.equal(cancelled.reply, "Cancellation requested."); function qqDedupAdapter(sent: OutgoingMessage[] = [], fail?: (outgoing: OutgoingMessage) => string | undefined): PlatformAdapter {
const used = new Set<string>();
return {
name: "test",
async handleWebhook() { return {}; },
async sendMessage(outgoing) {
const failure = fail?.(outgoing);
if (failure) throw new Error(failure);
if (outgoing.replyTo) {
const pair = `${outgoing.replyTo}|${outgoing.replySequence}`;
if (used.has(pair)) throw new Error("QQ send failed: HTTP 400 code=400304018 duplicate msg_id+msg_seq");
used.add(pair);
}
sent.push(outgoing);
}
};
}
function createGateway(runtime = new FakeRuntime()) {
return { runtime, gateway: new Gateway(policy, runtime) };
}
function messagesFor(sent: OutgoingMessage[], replyTo: string) {
return sent.filter((outgoing) => outgoing.replyTo === replyTo);
}
test("short asynchronous work sends only the real result with sequence one", async () => {
const { runtime, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
const turn = gateway.receive(message("work", "short"), recordingAdapter(sent));
await waitFor(() => runtime.turns.length === 1);
runtime.turns[0].resolve("done");
assert.deepEqual(await turn, { ok: true, reply: "done" });
assert.deepEqual(sent.map((outgoing) => [outgoing.text, outgoing.replySequence]), [["done", 1]]);
});
test("ordinary asynchronous messages send nothing before the runtime resolves", async () => {
const { runtime, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
const turn = gateway.receive(message("work", "no-timer"), recordingAdapter(sent));
await waitFor(() => runtime.turns.length === 1);
await flush();
assert.deepEqual(sent, []);
runtime.turns[0].resolve("done");
await turn; await turn;
const reset = await gateway.receive(message("/new"), adapter, { synchronous: true }); assert.deepEqual(sent.map((outgoing) => outgoing.text), ["done"]);
assert.match(reset.reply || "", /new native ACP session/);
assert.equal(runtime.resets, 1);
}); });
test("role aliases, role selection, and status are routed", async () => { test("ordinary messages reach the runtime immediately without a Gateway queue", async () => {
const runtime = new FakeRuntime(); const gateway = new Gateway(cfg.policy, runtime, new RoleRegistry(cfg)); const { runtime, gateway } = createGateway();
assert.match((await gateway.receive(message("/agents"), adapter, { synchronous: true })).reply || "", /Deprecated alias/); const sent: OutgoingMessage[] = [];
assert.equal((await gateway.receive(message("/role ops"), adapter, { synchronous: true })).reply, "Selected role: ops"); const adapter = recordingAdapter(sent);
assert.match((await gateway.receive(message("/status"), adapter, { synchronous: true })).reply || "", /role=ops/); const first = gateway.receive(message("one", "first"), adapter);
const second = gateway.receive(message("two", "second"), adapter);
await waitFor(() => runtime.turns.length === 2);
assert.deepEqual(sent, []);
runtime.turns[0].resolve("first done");
runtime.turns[1].resolve("second done");
await Promise.all([first, second]);
assert.deepEqual(messagesFor(sent, "first").map((outgoing) => [outgoing.text, outgoing.replySequence]), [["first done", 1]]);
assert.deepEqual(messagesFor(sent, "second").map((outgoing) => [outgoing.text, outgoing.replySequence]), [["second done", 1]]);
});
test("delivery failures are logged safely and do not block the result", async () => {
const { runtime, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
const errors: string[] = [];
const adapter: PlatformAdapter = {
name: "test", async handleWebhook() { return {}; },
async sendMessage(outgoing) {
sent.push(outgoing);
throw new Error("sensitive provider response");
}
};
const originalError = console.error;
console.error = (...args: unknown[]) => { errors.push(args.map(String).join(" ")); };
try {
const turn = gateway.receive(message("work", "failure"), adapter);
await waitFor(() => runtime.turns.length === 1);
runtime.turns[0].resolve("done");
assert.deepEqual(await turn, { ok: true, reply: "done" });
} finally {
console.error = originalError;
}
assert.deepEqual(sent.map((outgoing) => outgoing.text), ["done"]);
assert.deepEqual(errors, ["Gateway delivery send failed (platform=qq)"]);
});
test("runtime errors remain runtime errors and are delivered through the same stream", async () => {
const { runtime, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
const turn = gateway.receive(message("work", "runtime-error"), recordingAdapter(sent));
await waitFor(() => runtime.turns.length === 1);
runtime.turns[0].reject(new Error("failed"));
assert.deepEqual(await turn, { ok: false, error: "failed", reply: "Agent error: failed" });
assert.deepEqual(sent.map((outgoing) => [outgoing.text, outgoing.replySequence]), [["Agent error: failed", 1]]);
});
test("sendEvent uses a fresh passive window with replyTo and an incrementing sequence", async () => {
const { runtime, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
const turn = gateway.receive(message("work", "event-source"), recordingAdapter(sent));
await waitFor(() => runtime.turns.length === 1);
runtime.turns[0].resolve("done");
await turn;
await gateway.sendEvent("qq:chat", "任务完成了");
await gateway.sendEvent("qq:chat", "又完成了一步");
// The normal reply already consumed msg_seq 1 for "event-source"; events share the same counter.
const events = sent.filter((outgoing) => outgoing.text !== "done");
assert.deepEqual(events.map((outgoing) => [outgoing.text, outgoing.replyTo, outgoing.replySequence]), [
["任务完成了", "event-source", 2],
["又完成了一步", "event-source", 3]
]);
assert.deepEqual(events[0]!.target, { platform: "qq", chatId: "chat", userId: "user", raw: undefined });
});
test("sendEvent uses the most recent target and adapter for the chat", async () => {
const { runtime, gateway } = createGateway();
const firstSent: OutgoingMessage[] = [];
const secondSent: OutgoingMessage[] = [];
const first = gateway.receive(message("one", "one", "chat"), recordingAdapter(firstSent));
await waitFor(() => runtime.turns.length === 1);
const second = gateway.receive(message("two", "two", "chat"), recordingAdapter(secondSent));
await waitFor(() => runtime.turns.length === 2);
runtime.turns[0].resolve("one done");
runtime.turns[1].resolve("two done");
await Promise.all([first, second]);
await gateway.sendEvent("qq:chat", "event");
assert.equal(firstSent.filter((outgoing) => outgoing.text === "event").length, 0);
assert.equal(secondSent.at(-1)?.text, "event");
assert.equal(secondSent.at(-1)?.replyTo, "two");
assert.equal(secondSent.at(-1)?.replySequence, 2); // "two done" consumed seq 1 for message "two"
});
test("sendEvent drops safely when the chat has no known target", async () => {
const { gateway } = createGateway();
await gateway.sendEvent("qq:unknown", "event");
});
test("expired group passive window skips the event and reminds on the next inbound", async () => {
let now = 1_000_000;
const runtime = new FakeRuntime();
const gateway = new Gateway(policy, runtime, { now: () => now });
const sent: OutgoingMessage[] = [];
const adapter = recordingAdapter(sent);
const turn = gateway.receive(groupMessage("work", "m1"), adapter);
await waitFor(() => runtime.turns.length === 1);
runtime.turns[0].resolve("done");
await turn;
now += 4 * 60_000; // within the 4.5 minute group window
await gateway.sendEvent("qq:group:g1", "fresh event");
assert.deepEqual(sent.at(-1), {
target: { platform: "qq", chatId: "group:g1", userId: "user", raw: undefined },
text: "fresh event", replyTo: "m1", replySequence: 2 // "done" consumed seq 1 for m1
});
now += 2 * 60_000; // past the group window
await gateway.sendEvent("qq:group:g1", "stale event");
assert.equal(sent.filter((outgoing) => outgoing.text === "stale event").length, 0);
const status = await gateway.receive(groupMessage("/status", "m2"), adapter, { synchronous: true });
assert.match(status.reply || "", /lastEventDelivery=skipped/);
assert.match(status.reply || "", /lastEventError=no fresh passive window/);
assert.match(status.reply || "", /lastEventAt=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
const reminder = gateway.receive(groupMessage("hello", "m3"), adapter);
await waitFor(() => runtime.turns.length === 2);
runtime.turns[1].resolve("ok");
await reminder;
const reminded = sent.find((outgoing) => outgoing.text === "stale event");
assert.equal(reminded?.replyTo, "m3");
assert.equal(reminded?.replySequence, 1);
// The stale event was already flushed once; a later inbound must not repeat it.
const again = gateway.receive(groupMessage("hello again", "m4"), adapter);
await waitFor(() => runtime.turns.length === 3);
runtime.turns[2].resolve("ok");
await again;
assert.equal(sent.filter((outgoing) => outgoing.text === "stale event").length, 1);
});
test("status reports the last successful event delivery for the current chat", async () => {
const { runtime, gateway } = createGateway();
const adapter = recordingAdapter();
const turn = gateway.receive(message("work", "m1"), adapter);
await waitFor(() => runtime.turns.length === 1);
runtime.turns[0].resolve("done");
await turn;
await gateway.sendEvent("qq:chat", "event");
const status = await gateway.receive(message("/status", "m2"), adapter, { synchronous: true });
assert.match(status.reply || "", /lastEventDelivery=success/);
assert.match(status.reply || "", /lastEventError=none/);
assert.match(status.reply || "", /lastEventAt=\d{4}-\d{2}-\d{2}T/);
});
test("sendEvent delivers the text first, then images, all sharing the msg_seq counter", async () => {
const { runtime, gateway } = createGateway();
const { dir, file } = await tempPng("payload-v1");
try {
const sent: OutgoingMessage[] = [];
const turn = gateway.receive(message("work", "img-event"), recordingAdapter(sent, true));
await waitFor(() => runtime.turns.length === 1);
runtime.turns[0].resolve("done");
await turn;
await gateway.sendEvent("qq:chat", "截图好了", [{ path: file, filename: "shot.png" }]);
const pieces = sent.map((outgoing) => [outgoing.text, outgoing.replySequence, outgoing.images?.length || 0]);
assert.deepEqual(pieces, [
["done", 1, 0],
["截图好了", 2, 0],
["", 3, 1]
]);
const image = sent[2]!.images![0]!;
assert.equal(image.mimeType, "image/png");
assert.equal(image.filename, "shot.png");
assert.equal(Buffer.from(image.data, "base64").toString("latin1"), Buffer.concat([PNG_HEADER, Buffer.from("payload-v1")]).toString("latin1"));
} finally {
await fs.promises.rm(dir, { recursive: true, force: true });
}
});
test("a queued event re-reads its image at redelivery time and degrades when the file is gone", async () => {
let now = 1_000_000;
const runtime = new FakeRuntime();
const gateway = new Gateway(policy, runtime, { now: () => now });
const { dir, file } = await tempPng("payload-v1");
try {
const sent: OutgoingMessage[] = [];
const adapter = recordingAdapter(sent, true);
const first = gateway.receive(groupMessage("work", "m1"), adapter);
await waitFor(() => runtime.turns.length === 1);
runtime.turns[0].resolve("done");
await first;
now += 6 * 60_000; // past the group window: the event is queued instead of sent
await gateway.sendEvent("qq:group:g1", "stale event", [{ path: file, filename: "shot.png" }]);
assert.equal(sent.filter((outgoing) => outgoing.text === "stale event").length, 0);
// The worker updated the image after the event was queued: redelivery must send the new content.
await fs.promises.writeFile(file, Buffer.concat([PNG_HEADER, Buffer.from("payload-v2")]));
const second = gateway.receive(groupMessage("hello", "m2"), adapter);
await waitFor(() => runtime.turns.length === 2);
runtime.turns[1].resolve("ok");
await second;
const redelivered = sent.filter((outgoing) => outgoing.replyTo === "m2");
assert.deepEqual(redelivered.map((outgoing) => [outgoing.text, outgoing.replySequence, outgoing.images?.length || 0]), [
["stale event", 1, 0],
["", 2, 1],
["ok", 3, 0]
]);
assert.equal(Buffer.from(redelivered[1]!.images![0]!.data, "base64").toString("latin1"), Buffer.concat([PNG_HEADER, Buffer.from("payload-v2")]).toString("latin1"));
// Queue another event, then delete the file: the redelivery degrades to a text note.
now += 6 * 60_000;
await gateway.sendEvent("qq:group:g1", "another stale event", [{ path: file, filename: "shot.png" }]);
await fs.promises.rm(file);
const third = gateway.receive(groupMessage("hello again", "m3"), adapter);
await waitFor(() => runtime.turns.length === 3);
runtime.turns[2].resolve("ok again");
await third;
const degraded = sent.filter((outgoing) => outgoing.replyTo === "m3");
assert.equal(degraded.filter((outgoing) => outgoing.images?.length).length, 0);
assert.match(degraded[0]!.text, /another stale event/);
assert.match(degraded[0]!.text, /图片 shot\.png 发送失败/);
} finally {
await fs.promises.rm(dir, { recursive: true, force: true });
}
});
test("adapters without image support receive images flattened into text lines", async () => {
const { runtime, gateway } = createGateway();
const { dir, file } = await tempPng("payload-v1");
try {
const sent: OutgoingMessage[] = [];
const turn = gateway.receive(message("work", "flat"), recordingAdapter(sent));
await waitFor(() => runtime.turns.length === 1);
runtime.turns[0].resolve("done");
await turn;
await gateway.sendEvent("qq:chat", "截图好了", [{ path: file, filename: "shot.png" }]);
const event = sent.find((outgoing) => outgoing.text.includes("截图好了"))!;
assert.equal(event.images, undefined);
assert.match(event.text, /截图好了/);
assert.match(event.text, /\[图片\] shot\.png/);
assert.equal(sent.filter((outgoing) => outgoing.images?.length).length, 0);
} finally {
await fs.promises.rm(dir, { recursive: true, force: true });
}
});
test("an image send failure degrades to a text note without blocking the event", async () => {
const { runtime, gateway } = createGateway();
const { dir, file } = await tempPng("payload-v1");
try {
const sent: OutgoingMessage[] = [];
const errors: string[] = [];
const adapter: PlatformAdapter = {
name: "test", supportsImages: true, async handleWebhook() { return {}; },
async sendMessage(outgoing) {
if (outgoing.images?.length) throw new Error("sensitive provider response");
sent.push(outgoing);
}
};
const originalError = console.error;
console.error = (...args: unknown[]) => { errors.push(args.map(String).join(" ")); };
try {
const turn = gateway.receive(message("work", "img-fail"), adapter);
await waitFor(() => runtime.turns.length === 1);
runtime.turns[0].resolve("done");
await turn;
await gateway.sendEvent("qq:chat", "截图好了", [{ path: file, filename: "shot.png" }]);
} finally {
console.error = originalError;
}
const texts = sent.map((outgoing) => [outgoing.text, outgoing.replySequence]);
// The failed image attempt consumed msg_seq 3 (never reused in case QQ actually received it).
assert.deepEqual(texts, [
["done", 1],
["截图好了", 2],
["(图片 shot.png 发送失败)", 4]
]);
assert.deepEqual(errors, ["Gateway event image send failed (platform=qq)"]);
} finally {
await fs.promises.rm(dir, { recursive: true, force: true });
}
});
test("normal replies and fresh events share one msg_seq counter per inbound message", async () => {
const runtime = new FakeRuntime();
const gateway = new Gateway(policy, runtime);
const sent: OutgoingMessage[] = [];
const turn = gateway.receive(groupMessage("work", "m1"), qqDedupAdapter(sent));
await waitFor(() => runtime.turns.length === 1);
runtime.turns[0].resolve("done");
await turn;
// With independent counters these events would reuse (m1, seq 1) and QQ would reject them.
await gateway.sendEvent("qq:group:g1", "event one");
await gateway.sendEvent("qq:group:g1", "event two");
assert.deepEqual(sent.map((outgoing) => [outgoing.text, outgoing.replyTo, outgoing.replySequence]), [
["done", "m1", 1],
["event one", "m1", 2],
["event two", "m1", 3]
]);
});
test("a redelivered inbound message id continues its msg_seq counter instead of restarting", async () => {
const runtime = new FakeRuntime();
const gateway = new Gateway(policy, runtime);
const sent: OutgoingMessage[] = [];
const adapter = qqDedupAdapter(sent);
const first = gateway.receive(groupMessage("work", "m1"), adapter);
await waitFor(() => runtime.turns.length === 1);
runtime.turns[0].resolve("first done");
await first;
// QQ pushed the same msg_id again: the reply must not reuse (m1, seq 1).
const second = gateway.receive(groupMessage("work", "m1"), adapter);
await waitFor(() => runtime.turns.length === 2);
runtime.turns[1].resolve("second done");
await second;
assert.deepEqual(sent.map((outgoing) => [outgoing.text, outgoing.replyTo, outgoing.replySequence]), [
["first done", "m1", 1],
["second done", "m1", 2]
]);
});
test("flushed pending events and the current reply share the msg_seq counter", async () => {
let now = 1_000_000;
const runtime = new FakeRuntime();
const gateway = new Gateway(policy, runtime, { now: () => now });
const sent: OutgoingMessage[] = [];
const adapter = qqDedupAdapter(sent);
const first = gateway.receive(groupMessage("work", "m1"), adapter);
await waitFor(() => runtime.turns.length === 1);
runtime.turns[0].resolve("done");
await first;
now += 6 * 60_000; // past the group window: the event is queued instead of sent
await gateway.sendEvent("qq:group:g1", "stale event");
assert.equal(sent.filter((outgoing) => outgoing.text === "stale event").length, 0);
const second = gateway.receive(groupMessage("hello", "m2"), adapter);
await waitFor(() => runtime.turns.length === 2);
runtime.turns[1].resolve("ok");
await second;
// The redelivery takes (m2, seq 1) and the current turn's reply takes (m2, seq 2); no pair collides.
assert.deepEqual(sent.map((outgoing) => [outgoing.text, outgoing.replyTo, outgoing.replySequence]), [
["done", "m1", 1],
["stale event", "m2", 1],
["ok", "m2", 2]
]);
});
test("failed pending event redelivery stays queued, records failed, and retries on the next inbound", async () => {
let now = 1_000_000;
const runtime = new FakeRuntime();
const gateway = new Gateway(policy, runtime, { now: () => now });
const sent: OutgoingMessage[] = [];
let failEvent = true;
const adapter = qqDedupAdapter(sent, (outgoing) => failEvent && outgoing.text === "stale event"
? "QQ send failed: HTTP 400 code=40034105 proactive message not allowed"
: undefined);
const first = gateway.receive(groupMessage("work", "m1"), adapter);
await waitFor(() => runtime.turns.length === 1);
runtime.turns[0].resolve("done");
await first;
now += 6 * 60_000;
await gateway.sendEvent("qq:group:g1", "stale event"); // skipped, queued
const second = gateway.receive(groupMessage("hello", "m2"), adapter);
await waitFor(() => runtime.turns.length === 2);
runtime.turns[1].resolve("ok");
await second;
// The redelivery failed: nothing was delivered, but the normal reply still went out.
assert.equal(sent.filter((outgoing) => outgoing.text === "stale event").length, 0);
assert.deepEqual(sent.map((outgoing) => [outgoing.text, outgoing.replyTo, outgoing.replySequence]), [
["done", "m1", 1],
["ok", "m2", 2]
]);
const status = await gateway.receive(groupMessage("/status", "ms"), adapter, { synchronous: true });
assert.match(status.reply || "", /lastEventDelivery=failed/);
assert.match(status.reply || "", /lastEventError=HTTP 400 QQ code 40034105/);
failEvent = false;
const third = gateway.receive(groupMessage("hello again", "m3"), adapter);
await waitFor(() => runtime.turns.length === 3);
runtime.turns[2].resolve("ok again");
await third;
const redelivered = sent.filter((outgoing) => outgoing.text === "stale event");
assert.equal(redelivered.length, 1);
assert.equal(redelivered[0]!.replyTo, "m3");
assert.equal(redelivered[0]!.replySequence, 1);
});
test("sendEvent delivery failures are logged safely without message content", async () => {
const { runtime, gateway } = createGateway();
const errors: string[] = [];
const adapter: PlatformAdapter = {
name: "test", async handleWebhook() { return {}; },
async sendMessage(outgoing) {
if (outgoing.text.includes("event text")) throw new Error("sensitive provider response");
}
};
const turn = gateway.receive(message("work", "event-failure"), adapter);
await waitFor(() => runtime.turns.length === 1);
runtime.turns[0].resolve("done");
await turn;
const originalError = console.error;
console.error = (...args: unknown[]) => { errors.push(args.map(String).join(" ")); };
try {
await gateway.sendEvent("qq:chat", "event text must not leak");
} finally {
console.error = originalError;
}
assert.deepEqual(errors, ["Gateway event send failed (platform=qq)"]);
});
test("status and help reach the runtime or reply directly", async () => {
const { runtime, gateway } = createGateway();
const adapter = recordingAdapter();
const turn = gateway.receive(message("work"), adapter, { synchronous: true });
await waitFor(() => runtime.turns.length === 1);
const status = await gateway.receive(message("/status"), adapter, { synchronous: true });
assert.match(status.reply || "", /running=true/);
assert.equal(runtime.commandUsers.status, "user");
const help = await gateway.receive(message("/help"), adapter, { synchronous: true });
assert.match(help.reply || "", /自然语言/);
assert.match(help.reply || "", /\/confirm/);
assert.match(help.reply || "", /\/finish/);
assert.match(help.reply || "", /\/stop/);
assert.match(help.reply || "", /\/cancel/);
assert.match(help.reply || "", /\/reset/);
runtime.turns[0].resolve("done");
await turn;
});
test("list, confirm, finish, stop, and cancel forward to the runtime with the chat identity", async () => {
const { runtime, gateway } = createGateway();
const adapter = recordingAdapter();
runtime.proposals = [fakeProposal()];
const list = await gateway.receive(message("/list"), adapter, { synchronous: true });
assert.equal(runtime.listed, 1);
assert.match(list.reply || "", /未确认(proposed)/);
assert.match(list.reply || "", /proposal-1「修复登录页」/);
runtime.proposals = [];
const emptyList = await gateway.receive(message("/list"), adapter, { synchronous: true });
assert.equal(emptyList.reply, "当前没有提案。");
const confirm = await gateway.receive(message("/confirm"), adapter, { synchronous: true });
assert.equal(runtime.confirmed, 1);
assert.equal(confirm.reply, "已确认,加入队列。");
const finish = await gateway.receive(message("/finish"), adapter, { synchronous: true });
assert.equal(runtime.finished, 1);
assert.equal(finish.reply, "已结束该任务。");
const stop = await gateway.receive(message("/stop"), adapter, { synchronous: true });
assert.equal(runtime.stopped, 1);
assert.equal(stop.reply, "已停止当前任务,任务转为待确认。");
const cancel = await gateway.receive(message("/cancel"), adapter, { synchronous: true });
assert.equal(runtime.cancelled, 1);
assert.equal(cancel.reply, "已取消最近的提案。");
assert.equal(runtime.commandUsers.list, "user");
assert.equal(runtime.commandUsers.confirm, "user");
assert.equal(runtime.commandUsers.finish, "user");
assert.equal(runtime.commandUsers.stop, "user");
assert.equal(runtime.commandUsers.cancel, "user");
runtime.confirmResult = false;
runtime.finishResult = false;
runtime.stopResult = false;
runtime.cancelResult = false;
assert.equal((await gateway.receive(message("/confirm"), adapter, { synchronous: true })).reply, "当前没有待确认的提案。");
assert.equal((await gateway.receive(message("/finish"), adapter, { synchronous: true })).reply, "当前没有待结束的任务。");
assert.equal((await gateway.receive(message("/stop"), adapter, { synchronous: true })).reply, "当前没有正在执行的任务。");
assert.equal((await gateway.receive(message("/cancel"), adapter, { synchronous: true })).reply, "没有可取消的提案。");
});
test("the list panel orders pending first, then working, queued, proposed, and recent finished", async () => {
const { runtime, gateway } = createGateway();
const adapter = recordingAdapter();
runtime.proposals = [
fakeProposal({ id: "p-finished", title: "旧任务", status: "finished", finishKind: "done", finishedAt: 10 }),
fakeProposal({ id: "p-proposed", title: "新想法", status: "proposed" }),
fakeProposal({ id: "p-working", title: "干活中", status: "working" }),
fakeProposal({ id: "p-queued", title: "排队任务", status: "queued" }),
fakeProposal({ id: "p-pending", title: "待确认任务", status: "pending", pending: { summary: "做了一半", question: "继续吗?", receivedAt: 5 } }),
fakeProposal({ id: "p-cancelled", title: "取消任务", status: "finished", finishKind: "cancelled", finishedAt: 20 })
];
const list = await gateway.receive(message("/list"), adapter, { synchronous: true });
const reply = list.reply || "";
const order = ["待确认", "进行中", "排队中", "未确认(proposed)", "最近结束"]
.map((section) => reply.indexOf(section));
assert.ok(order.every((index) => index >= 0), reply);
assert.deepEqual([...order].sort((a, b) => a - b), order);
assert.match(reply, /p-pending「待确认任务」(你的):做了一半(问你:继续吗?)/);
assert.match(reply, /\/finish 结束/);
assert.match(reply, /p-working「干活中」(你的)正在执行/);
assert.match(reply, /p-cancelled「取消任务」(你的)已取消/);
assert.match(reply, /p-finished「旧任务」(你的)已完成/);
});
test("/reset resets the current conversation session and reminds about unfinished proposals", async () => {
const { runtime, gateway } = createGateway();
const adapter = recordingAdapter();
const noSession = await gateway.receive(message("/reset"), adapter, { synchronous: true });
assert.equal(noSession.reply, "当前没有需要重置的会话。");
runtime.resetResult = true;
const reset = await gateway.receive(message("/reset"), adapter, { synchronous: true });
assert.match(reset.reply || "", /已重置当前对话/);
assert.doesNotMatch(reset.reply || "", /未完成任务/);
assert.equal(runtime.commandUsers.reset, "user");
runtime.proposals = [fakeProposal({ id: "p-1", status: "pending", pending: { summary: "s", receivedAt: 1 } })];
const reminded = await gateway.receive(message("/reset"), adapter, { synchronous: true });
assert.match(reminded.reply || "", /已重置当前对话/);
assert.match(reminded.reply || "", /你还有 1 个未完成任务,proposal 板不受影响/);
});
test("the list panel shows other members' proposals without exposing their identity", async () => {
const { runtime, gateway } = createGateway();
const adapter = recordingAdapter();
runtime.proposals = [
fakeProposal({ id: "p-mine", title: "我的任务", status: "working" }),
fakeProposal({ id: "p-other", title: "别人的任务", status: "pending", ownerChatKey: "qq:group:g-secret-9", requesterUserId: "someone-else", pending: { summary: "做到一半", receivedAt: 5 } })
];
const list = await gateway.receive(message("/list"), adapter, { synchronous: true });
const reply = list.reply || "";
assert.match(reply, /p-other「别人的任务」(其他成员):做到一半/);
assert.match(reply, /p-mine「我的任务」(你的)正在执行/);
assert.doesNotMatch(reply, /someone-else/);
assert.doesNotMatch(reply, /g-secret-9/);
});
test("synchronous work sends no delivery messages", async () => {
const { runtime, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
const turn = gateway.receive(message("work"), recordingAdapter(sent), { synchronous: true });
await waitFor(() => runtime.turns.length === 1);
runtime.turns[0].resolve("done");
assert.deepEqual(await turn, { ok: true, reply: "done" });
assert.deepEqual(sent, []);
});
test("asynchronous help replies with sequence one without reaching the runtime", async () => {
const { runtime, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
await gateway.receive(message("/help", "help"), recordingAdapter(sent));
assert.deepEqual(messagesFor(sent, "help").map((outgoing) => outgoing.replySequence), [1]);
assert.equal(runtime.turns.length, 0);
});
test("asynchronous commands reply with sequence one without reaching prompt", async () => {
const { runtime, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
runtime.proposals = [fakeProposal()];
const result = await gateway.receive(message("/list", "list"), recordingAdapter(sent));
assert.equal(result.ok, true);
assert.equal(runtime.turns.length, 0);
assert.deepEqual(messagesFor(sent, "list").map((outgoing) => outgoing.replySequence), [1]);
assert.match(messagesFor(sent, "list")[0].text, /proposal-1/);
}); });
+132
View File
@@ -0,0 +1,132 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import { EventEmitter } from "node:events";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { configDigest, writeConfigFile } from "../src/cli/config-file.js";
import { assertSetupPidSafe, instanceDirectory, instanceRunnerPath, matchesInstanceRunner, runInstanceCommand } from "../src/cli/instance.js";
import { assertRunnerConfigSnapshot, assertRunnerWorkspaceSafety, runInstanceRunner, waitForShutdown } from "../src/cli/instance-runner.js";
import { parseConfig } from "../src/config.js";
test("instance paths reject traversal and config identity mismatch", async () => {
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-instances-"));
const previous = process.env.GORI_AGENT_ROOT;
process.env.GORI_AGENT_ROOT = root;
try {
assert.throws(() => instanceDirectory("../escape"), /Invalid bot ID/);
assert.throws(() => instanceDirectory("Uppercase"), /Invalid bot ID/);
await assert.rejects(runInstanceCommand("list", ["extra"]), /does not accept/);
await assert.rejects(runInstanceCommand("status", ["one", "two"]), /exactly one/);
await assert.rejects(runInstanceCommand("unknown", ["bot"]), /Unknown command/);
await assert.rejects(runInstanceCommand("status", ["missing-bot"]), /Runtime config does not exist/);
const directory = instanceDirectory("directory-bot");
const config = parseConfig({
configVersion: 3,
bot: { id: "different-bot", workspace: "/tmp", persona: "", agent: { id: "kimi", command: "kimi", args: ["acp"] }, permissions: { mode: "deny" } },
gateway: { platform: { type: "webhook", secret: "secret" } },
runtime: {}
});
writeConfigFile(path.join(directory, "config.json"), config);
await assert.rejects(runInstanceCommand("status", ["directory-bot"]), /does not match config bot\.id/);
const privateDirectory = instanceDirectory("private-bot");
writeConfigFile(path.join(privateDirectory, "config.json"), parseConfig({
configVersion: 3,
bot: { id: "private-bot", workspace: "/tmp", persona: "", agent: { id: "kimi", command: "kimi", args: ["acp"] }, permissions: { mode: "deny" } },
gateway: { platform: { type: "webhook", secret: "secret" } },
runtime: {}
}));
await fs.promises.chmod(privateDirectory, 0o755);
await assert.rejects(runInstanceCommand("status", ["private-bot"]), /mode must be 0700/);
} finally {
if (previous === undefined) delete process.env.GORI_AGENT_ROOT;
else process.env.GORI_AGENT_ROOT = previous;
}
});
test("setup rejects both an owned runner PID and a foreign PID", () => {
assert.throws(() => assertSetupPidSafe("safe-bot", { kind: "running", pid: 123 }), /instance 'safe-bot' is running/);
assert.throws(() => assertSetupPidSafe("safe-bot", { kind: "foreign", pid: 456 }), /unrelated live process/);
assert.doesNotThrow(() => assertSetupPidSafe("safe-bot", { kind: "absent" }));
assert.doesNotThrow(() => assertSetupPidSafe("safe-bot", { kind: "stale" }));
});
test("runner identity requires exact executable, runner, and config arguments", () => {
const configFile = path.resolve("/tmp/test-instance-config.json");
const commandLine = [process.execPath, instanceRunnerPath(), configFile];
assert.equal(matchesInstanceRunner(commandLine, process.execPath, configFile), true);
assert.equal(matchesInstanceRunner([...commandLine, "extra"], process.execPath, configFile), false);
assert.equal(matchesInstanceRunner([process.execPath, path.resolve("dist/cli.js"), configFile], process.execPath, configFile), false);
assert.equal(matchesInstanceRunner(commandLine, "/bin/sh", configFile), false);
assert.equal(matchesInstanceRunner(commandLine, process.execPath, `${configFile}.other`), false);
});
test("instance runner rejects unsafe argument and config paths before starting a server", async () => {
await assert.rejects(runInstanceRunner([]), /exactly one config path/);
await assert.rejects(runInstanceRunner(["one", "two"]), /exactly one config path/);
await assert.rejects(runInstanceRunner([path.join(os.tmpdir(), `missing-runner-${Date.now()}.json`)]), /Runtime config does not exist/);
});
test("instance runner binds startup to the validated config snapshot and repeats workspace checks", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-runner-safety-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const parent = path.join(root, "workspace");
const child = path.join(parent, "child");
fs.mkdirSync(child, { recursive: true });
const config = parseConfig({
configVersion: 3,
bot: { id: "runner-bot", workspace: child, persona: "test", agent: { id: "kimi", command: "kimi", args: ["acp"] }, permissions: { mode: "deny" } },
gateway: { platform: { type: "webhook", secret: "secret" } },
runtime: {}
});
const configFile = path.join(root, "instances", "runner-bot", "config.json");
writeConfigFile(configFile, config);
assert.doesNotThrow(() => assertRunnerConfigSnapshot(config, configDigest(config)));
assert.throws(() => assertRunnerConfigSnapshot(config, "changed"), /config changed/);
assert.throws(() => assertRunnerWorkspaceSafety(config, path.join(root, "wrong.json"), root), /directory contract/);
const peer = parseConfig({ ...config, bot: { ...config.bot, id: "peer-bot", workspace: parent } });
writeConfigFile(path.join(root, "instances", "peer-bot", "config.json"), peer);
assert.throws(() => assertRunnerWorkspaceSafety(config, configFile, root), /identical, parent, or child workspace/);
});
test("instance runner gracefully shuts down once on SIGTERM", async () => {
const signals = new EventEmitter();
let shutdowns = 0;
const result = waitForShutdown({ shutdown: async () => { shutdowns++; } }, signals);
signals.emit("SIGTERM");
signals.emit("SIGINT");
assert.equal(await result, 0);
assert.equal(shutdowns, 1);
assert.equal(signals.listenerCount("SIGINT"), 0);
assert.equal(signals.listenerCount("SIGTERM"), 0);
});
test("setup refuses a foreign live PID and unsafe PID file", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-setup-pid-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const previous = process.env.GORI_AGENT_ROOT;
process.env.GORI_AGENT_ROOT = root;
t.after(() => {
if (previous === undefined) delete process.env.GORI_AGENT_ROOT;
else process.env.GORI_AGENT_ROOT = previous;
});
const directory = instanceDirectory("safe-bot");
writeConfigFile(path.join(directory, "config.json"), parseConfig({
configVersion: 3,
bot: { id: "safe-bot", workspace: os.tmpdir(), persona: "", agent: { id: "node", command: process.execPath, args: ["acp"] }, permissions: { mode: "deny" } },
gateway: { platform: { type: "webhook", secret: "test-secret" } },
runtime: {}
}));
fs.mkdirSync(path.join(directory, "state"), { mode: 0o700 });
fs.mkdirSync(path.join(directory, "logs"), { mode: 0o700 });
const pidFile = path.join(directory, "state", "gori-agent.pid");
fs.writeFileSync(pidFile, `${process.pid}\n`, { mode: 0o600 });
await assert.rejects(runInstanceCommand("setup", ["safe-bot"]), /unrelated live process/);
fs.chmodSync(pidFile, 0o644);
await assert.rejects(runInstanceCommand("setup", ["safe-bot"]), /PID file mode must be 0600/);
});
+232
View File
@@ -0,0 +1,232 @@
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 { ProposalStore } from "../src/core/proposal-store.js";
const identity = { botId: "test-bot", platform: "qq" };
const input = { title: "Refactor store", goal: "Move to assistant proposals", steps: ["design", "implement", "test"], ownerChatKey: "qq:chat", requesterUserId: "user-1" };
test("creates, updates and reloads proposals with 0600 atomic file", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-proposals-"));
const file = path.join(dir, "proposals.json");
const first = new ProposalStore(file, identity);
await first.open();
const created = await first.create(input);
assert.equal(created.status, "proposed");
assert.ok(created.id.length > 0);
assert.equal(created.createdAt, created.updatedAt);
const confirmedAt = created.createdAt + 1000;
const updated = await first.update(created.id, {
status: "pending",
confirmedAt,
startedAt: confirmedAt + 1,
workerNativeSessionId: "native-1",
workerProcessGroup: { pgid: 4321, token: "worker-token" },
pending: { summary: "needs dirty confirm", question: "overwrite?", workspaceDirty: true, receivedAt: confirmedAt + 2 },
lastWorkerSummary: "half done"
});
assert.equal(updated.status, "pending");
assert.ok(updated.updatedAt >= created.updatedAt);
const finished = await first.update(created.id, {
status: "finished",
finishKind: "done",
finishNote: "wrapped up",
pending: undefined,
finishedAt: confirmedAt + 3
});
assert.equal(finished.finishKind, "done");
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");
assert.equal(persisted.proposals[created.id].workerProcessGroup.pgid, 4321);
const second = new ProposalStore(file, identity);
await second.open();
const loaded = second.get(created.id);
assert.deepEqual(loaded, finished);
assert.deepEqual(second.list().map((proposal) => proposal.id), [created.id]);
assert.deepEqual(second.list({ status: "finished" }).map((proposal) => proposal.id), [created.id]);
assert.equal(second.list({ status: "queued" }).length, 0);
assert.equal(second.stats().proposals, 1);
await second.close();
});
test("orders list by confirmation time for queued proposals", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-proposals-order-"));
const file = path.join(dir, "proposals.json");
const store = new ProposalStore(file, identity);
await store.open();
const firstCreated = await store.create({ ...input, title: "first created" });
const secondCreated = await store.create({ ...input, title: "second created" });
await store.update(secondCreated.id, { status: "queued", confirmedAt: 100 });
await store.update(firstCreated.id, { status: "queued", confirmedAt: 200 });
assert.deepEqual(store.list({ status: "queued" }).map((proposal) => proposal.title), ["second created", "first created"]);
await store.close();
});
test("validates proposal fields on create and update", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-proposals-invalid-"));
const file = path.join(dir, "proposals.json");
const store = new ProposalStore(file, identity);
await store.open();
await assert.rejects(store.create({ ...input, title: "" }), /title/);
await assert.rejects(store.create({ ...input, steps: ["ok", ""] }), /steps/);
const created = await store.create(input);
await assert.rejects(store.update(created.id, { status: "bogus" as never }), /status/);
await assert.rejects(store.update(created.id, { workerProcessGroup: { pgid: 1, token: "t" } }), /workerProcessGroup/);
await assert.rejects(store.update(created.id, { pending: { summary: "s" } as never }), /pending/);
await assert.rejects(store.update(created.id, { pending: { summary: "s", receivedAt: "now" } as never }), /pending/);
await assert.rejects(store.update(created.id, { finishKind: "bogus" as never }), /finishKind/);
await assert.rejects(store.update("missing", { status: "queued" }), /unknown proposal/);
assert.equal(store.get(created.id)?.status, "proposed");
await store.close();
});
test("rejects identity or version mismatch and preserves corrupt state", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-proposals-id-"));
const file = path.join(dir, "proposals.json");
const wrongVersion = '{"version":3,"botId":"test-bot","platform":"qq","proposals":{}}\n';
await fs.promises.writeFile(file, wrongVersion);
await assert.rejects(new ProposalStore(file, identity).open(), /expected version 2/);
assert.equal(await fs.promises.readFile(file, "utf8"), wrongVersion);
await fs.promises.writeFile(file, JSON.stringify({ version: 2, botId: "other", platform: "qq", proposals: {} }));
await assert.rejects(new ProposalStore(file, identity).open(), /identity mismatch/);
await fs.promises.writeFile(file, JSON.stringify({ version: 2, botId: "test-bot", platform: "feishu", proposals: {} }));
await assert.rejects(new ProposalStore(file, identity).open(), /identity mismatch/);
await fs.promises.writeFile(file, "not-json");
await assert.rejects(new ProposalStore(file, identity).open(), /original file was preserved/);
assert.equal(await fs.promises.readFile(file, "utf8"), "not-json");
await fs.promises.writeFile(file, JSON.stringify({ version: 2, botId: "test-bot", platform: "qq", proposals: { abc: { id: "other" } } }));
await assert.rejects(new ProposalStore(file, identity).open(), /invalid proposal/);
});
function v1Proposal(id: string, status: string, extra: Record<string, unknown> = {}) {
return {
id,
title: `title-${id}`,
goal: "goal",
steps: ["step"],
ownerChatKey: "qq:chat",
requesterUserId: "user-1",
status,
createdAt: 1,
updatedAt: 2,
...extra
};
}
test("migrates a v1 store: backs up the original file and maps every legacy status", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-proposals-migrate-"));
const file = path.join(dir, "proposals.json");
const v1 = {
version: 1,
botId: "test-bot",
platform: "qq",
proposals: {
success: v1Proposal("success", "awaiting_user_confirmation", { pending: { kind: "success", summary: "all done" } }),
failure: v1Proposal("failure", "awaiting_user_confirmation", { pending: { kind: "failure", summary: "it broke" } }),
step: v1Proposal("step", "awaiting_user_confirmation", { pending: { kind: "step", summary: "dirty target", question: "overwrite?", nextStep: "edit" } }),
done: v1Proposal("done", "completed", { finishedAt: 10 }),
broken: v1Proposal("broken", "failed", { finishedAt: 11, lastWorkerSummary: "worker blew up" }),
dropped: v1Proposal("dropped", "cancelled", { finishedAt: 12 }),
fresh: v1Proposal("fresh", "proposed"),
lined: v1Proposal("lined", "queued", { confirmedAt: 5 }),
busy: v1Proposal("busy", "working", { startedAt: 6, workerNativeSessionId: "native-9", workerProcessGroup: { pgid: 4321, token: "tok" } })
}
};
const raw = `${JSON.stringify(v1, null, 2)}\n`;
await fs.promises.writeFile(file, raw, { mode: 0o600 });
const store = new ProposalStore(file, identity);
await store.open();
const backups = (await fs.promises.readdir(dir)).filter((name) => name.startsWith("proposals.json.v1-") && name.endsWith(".bak"));
assert.equal(backups.length, 1);
assert.equal((await fs.promises.stat(path.join(dir, backups[0]!))).mode & 0o777, 0o600);
assert.equal(await fs.promises.readFile(path.join(dir, backups[0]!), "utf8"), raw);
const persisted = JSON.parse(await fs.promises.readFile(file, "utf8"));
assert.equal(persisted.version, 2);
assert.equal((await fs.promises.stat(file)).mode & 0o777, 0o600);
const success = store.get("success")!;
assert.equal(success.status, "pending");
assert.deepEqual(success.pending, { summary: "all done", receivedAt: 2 });
assert.equal(success.pending?.workspaceDirty, undefined);
const failure = store.get("failure")!;
assert.equal(failure.status, "pending");
assert.equal(failure.pending?.summary, "it broke");
assert.equal(failure.pending?.workspaceDirty, true);
const step = store.get("step")!;
assert.equal(step.status, "pending");
assert.equal(step.pending?.summary, "dirty target");
assert.equal(step.pending?.question, "overwrite?");
assert.equal((step.pending as unknown as Record<string, unknown>).nextStep, undefined);
const done = store.get("done")!;
assert.equal(done.status, "finished");
assert.equal(done.finishKind, "done");
assert.equal(done.finishedAt, 10);
const broken = store.get("broken")!;
assert.equal(broken.status, "finished");
assert.equal(broken.finishKind, "done");
assert.equal(broken.finishNote, "worker blew up");
const dropped = store.get("dropped")!;
assert.equal(dropped.status, "finished");
assert.equal(dropped.finishKind, "cancelled");
assert.equal(store.get("fresh")!.status, "proposed");
assert.equal(store.get("lined")!.status, "queued");
const busy = store.get("busy")!;
assert.equal(busy.status, "working");
assert.equal(busy.workerNativeSessionId, "native-9");
assert.deepEqual(busy.workerProcessGroup, { pgid: 4321, token: "tok" });
await store.close();
// The migrated store reloads as a plain v2 file without creating another backup.
const reloaded = new ProposalStore(file, identity);
await reloaded.open();
assert.equal(reloaded.stats().proposals, 9);
await reloaded.close();
const backupsAfter = (await fs.promises.readdir(dir)).filter((name) => name.endsWith(".bak"));
assert.equal(backupsAfter.length, 1);
});
test("a v1 store with a mismatched identity is rejected before any backup is written", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-proposals-migrate-id-"));
const file = path.join(dir, "proposals.json");
const raw = `${JSON.stringify({ version: 1, botId: "other-bot", platform: "qq", proposals: {} })}\n`;
await fs.promises.writeFile(file, raw, { mode: 0o600 });
await assert.rejects(new ProposalStore(file, identity).open(), /identity mismatch/);
assert.equal(await fs.promises.readFile(file, "utf8"), raw);
assert.equal((await fs.promises.readdir(dir)).filter((name) => name.endsWith(".bak")).length, 0);
});
test("refuses a second writer and recovers a stale lock", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-proposals-lock-"));
const file = path.join(dir, "proposals.json");
const first = new ProposalStore(file, identity); await first.open();
await assert.rejects(new ProposalStore(file, identity).open(), /locked by another/);
await first.close();
await fs.promises.writeFile(`${file}.lock`, "2147483647\n", { mode: 0o600 });
const recovered = new ProposalStore(file, identity);
await recovered.open();
await recovered.close();
assert.equal(fs.existsSync(`${file}.lock`), false);
});
+259 -13
View File
@@ -1,34 +1,280 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; 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"; 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: { const config: QqConfig = {
enabled: true, connectionMode: "webhook", appId: "id", clientSecret: "secret", verifySignature: false, botNames: ["Bot"] 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 () => { test("normalizes GROUP and C2C author openid while webhook ACK remains immediate", async () => {
const received: any[] = []; const received: any[] = [];
const gateway = { receive: async (message: unknown) => { received.push(message); throw new Error("ACP failed"); } }; 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 group = await adapter.handleWebhook({ body: { op: 0, t: "GROUP_AT_MESSAGE_CREATE", d: { id: "m1", group_openid: "g1", author: { user_openid: "u1", member_openid: "mem1" }, 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 }); const c2c = await adapter.handleWebhook({ body: { op: 0, t: "C2C_MESSAGE_CREATE", d: { id: "m2", author: { user_openid: "u2", member_openid: "mem2" }, content: "hello" } }, headers: {}, query: {}, req: {} as never });
assert.deepEqual(group.body, { op: 12 }); assert.deepEqual(c2c.body, { op: 12 }); assert.deepEqual(group.body, { op: 12 }); assert.deepEqual(c2c.body, { op: 12 });
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
assert.equal(received[0].chatId, "group:g1"); assert.equal(received[0].userId, "u1"); assert.equal(received[0].chatId, "group:g1"); assert.equal(received[0].userId, "mem1");
assert.equal(received[1].chatId, "user:u2"); assert.equal(received[1].userId, "u2"); assert.equal(received[1].chatId, "user:u2"); assert.equal(received[1].userId, "u2");
}); });
test("sendMessage uses nested author.user_openid for C2C endpoint", async () => { test("group messages without member_openid fall back to user_openid", async () => {
const original = globalThis.fetch; const urls: string[] = []; const received: any[] = [];
const gateway = { receive: async (message: unknown) => { received.push(message); } };
const adapter = new QqAdapter(config, gateway as never);
await adapter.handleWebhook({ body: { op: 0, t: "GROUP_AT_MESSAGE_CREATE", d: { id: "m3", group_openid: "g1", author: { user_openid: "u3" }, content: "@Bot hi" } }, headers: {}, query: {}, req: {} as never });
await adapter.handleWebhook({ body: { op: 0, t: "GROUP_AT_MESSAGE_CREATE", d: { id: "m4", group_openid: "g1", member_openid: "mem4", author: {}, content: "@Bot hi" } }, headers: {}, query: {}, req: {} as never });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(received[0].userId, "u3");
assert.equal(received[1].userId, "mem4");
});
test("image attachments are downloaded to base64 while video and file attachments degrade to text links", async () => {
const received: any[] = [];
const gateway = { receive: async (message: unknown) => { received.push(message); } };
const adapter = new QqAdapter(config, gateway as never);
const pngBytes = Buffer.from("fake-png-bytes");
const urls: string[] = [];
const original = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request) => { globalThis.fetch = (async (input: string | URL | Request) => {
urls.push(String(input));
return new Response(pngBytes, { status: 200 });
}) as typeof fetch;
try {
await adapter.handleWebhook({
body: {
op: 0, t: "C2C_MESSAGE_CREATE",
d: {
id: "m10", author: { user_openid: "u10" }, content: "看看这些",
attachments: [
{ content_type: "image/png", filename: "a.png", size: pngBytes.length, url: "//cdn.example.com/a.png" },
{ content_type: "video/mp4", filename: "v.mp4", size: 1024, url: "https://cdn.example.com/v.mp4" },
{ content_type: "application/pdf", filename: "f.pdf", size: 1024, url: "https://cdn.example.com/f.pdf" }
]
}
},
headers: {}, query: {}, req: {} as never
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(received.length, 1);
const message = received[0];
assert.deepEqual(urls, ["https://cdn.example.com/a.png"]);
assert.equal(message.attachments.length, 1);
assert.equal(message.attachments[0].mimeType, "image/png");
assert.equal(message.attachments[0].filename, "a.png");
assert.equal(message.attachments[0].data, pngBytes.toString("base64"));
assert.match(message.text, /^看看这些/);
assert.match(message.text, /\[视频\] https:\/\/cdn\.example\.com\/v\.mp4/);
assert.match(message.text, /\[文件\] https:\/\/cdn\.example\.com\/f\.pdf/);
} finally { globalThis.fetch = original; }
});
test("oversized images are skipped and images beyond the per-message cap are not downloaded", async () => {
const received: any[] = [];
const gateway = { receive: async (message: unknown) => { received.push(message); } };
const adapter = new QqAdapter(config, gateway as never);
const urls: string[] = [];
const original = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request) => {
urls.push(String(input));
return new Response(Buffer.from("x"), { status: 200 });
}) as typeof fetch;
try {
await adapter.handleWebhook({
body: {
op: 0, t: "C2C_MESSAGE_CREATE",
d: {
id: "m11", author: { user_openid: "u11" }, content: "hi",
attachments: [
{ content_type: "image/png", size: 6 * 1024 * 1024, url: "https://cdn.example.com/big.png" },
{ content_type: "image/png", size: 10, url: "https://cdn.example.com/1.png" },
{ content_type: "image/png", size: 10, url: "https://cdn.example.com/2.png" },
{ content_type: "image/png", size: 10, url: "https://cdn.example.com/3.png" },
{ content_type: "image/png", size: 10, url: "https://cdn.example.com/4.png" }
]
}
},
headers: {}, query: {}, req: {} as never
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(received.length, 1);
assert.deepEqual(urls, [
"https://cdn.example.com/1.png",
"https://cdn.example.com/2.png",
"https://cdn.example.com/3.png"
]);
assert.equal(received[0].attachments.length, 3);
} finally { globalThis.fetch = original; }
});
test("a pure image message uses placeholder text and failed downloads degrade", async () => {
const received: any[] = [];
const gateway = { receive: async (message: unknown) => { received.push(message); } };
const adapter = new QqAdapter(config, gateway as never);
const original = globalThis.fetch;
let fail = false;
globalThis.fetch = (async () => {
if (fail) throw new Error("network down");
return new Response(Buffer.from("img"), { status: 200 });
}) as typeof fetch;
try {
await adapter.handleWebhook({
body: { op: 0, t: "C2C_MESSAGE_CREATE", d: { id: "m12", author: { user_openid: "u12" }, content: "", attachments: [{ content_type: "image/jpeg", url: "https://cdn.example.com/a.jpg" }] } },
headers: {}, query: {}, req: {} as never
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(received.length, 1);
assert.equal(received[0].text, "(发来一张图片)");
assert.equal(received[0].attachments.length, 1);
// Every download failing with no text keeps the message ignored.
fail = true;
await adapter.handleWebhook({
body: { op: 0, t: "C2C_MESSAGE_CREATE", d: { id: "m13", author: { user_openid: "u12" }, content: "", attachments: [{ content_type: "image/jpeg", url: "https://cdn.example.com/b.jpg" }] } },
headers: {}, query: {}, req: {} as never
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(received.length, 1);
// Failed downloads with text still deliver the text without attachments.
await adapter.handleWebhook({
body: { op: 0, t: "C2C_MESSAGE_CREATE", d: { id: "m14", author: { user_openid: "u12" }, content: "看图", attachments: [{ content_type: "image/jpeg", url: "https://cdn.example.com/c.jpg" }] } },
headers: {}, query: {}, req: {} as never
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(received.length, 2);
assert.equal(received[1].text, "看图");
assert.equal(received[1].attachments, undefined);
} finally { globalThis.fetch = original; }
});
test("sendMessage uses C2C endpoint and forwards reply sequences as msg_seq", async () => {
const original = globalThis.fetch; const urls: string[] = []; const bodies: Array<Record<string, unknown>> = [];
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
urls.push(String(input)); urls.push(String(input));
if (String(input).includes("getAppAccessToken")) return new Response(JSON.stringify({ access_token: "token", expires_in: 7200 }), { status: 200 }); if (String(input).includes("getAppAccessToken")) return new Response(JSON.stringify({ access_token: "token", expires_in: 7200 }), { status: 200 });
bodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
return new Response(JSON.stringify({ id: "sent" }), { status: 200 }); return new Response(JSON.stringify({ id: "sent" }), { status: 200 });
}) as typeof fetch; }) as typeof fetch;
try { 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" }); const target = { platform: "qq", chatId: "user:u2", raw: { author: { user_openid: "u2" } } };
await adapter.sendMessage({ target, text: "first", replyTo: "m2", replySequence: 1 });
await adapter.sendMessage({ target, text: "later", replyTo: "m2", replySequence: 7 });
assert.ok(urls.some((url) => url.endsWith("/v2/users/u2/messages"))); assert.ok(urls.some((url) => url.endsWith("/v2/users/u2/messages")));
assert.deepEqual(bodies, [
{ content: "first", msg_id: "m2", msg_seq: 1 },
{ content: "later", msg_id: "m2", msg_seq: 7 }
]);
} finally { globalThis.fetch = original; }
});
test("sendMessage uploads images via /files and sends msg_type 7 media with the shared msg_seq", async () => {
const original = globalThis.fetch; const urls: string[] = []; const bodies: Array<Record<string, unknown>> = [];
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
urls.push(url);
if (url.includes("getAppAccessToken")) return new Response(JSON.stringify({ access_token: "token", expires_in: 7200 }), { status: 200 });
bodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
if (url.endsWith("/files")) return new Response(JSON.stringify({ file_info: "FILEINFO", ttl: 60 }), { status: 200 });
return new Response(JSON.stringify({ id: "sent" }), { status: 200 });
}) as typeof fetch;
try {
const adapter = new QqAdapter(config, { receive: async () => ({ ok: true }) } as never);
const target = { platform: "qq", chatId: "group:g1", raw: { group_openid: "g1" } };
const image = { mimeType: "image/png", data: Buffer.from("fake-png").toString("base64"), filename: "shot.png" };
await adapter.sendMessage({ target, text: "", images: [image], replyTo: "m1", replySequence: 3 });
const apiCalls = urls.filter((url) => !url.includes("getAppAccessToken")).map((url) => url.replace("https://api.sgroup.qq.com", ""));
assert.deepEqual(apiCalls, ["/v2/groups/g1/files", "/v2/groups/g1/messages"]);
assert.deepEqual(bodies, [
{ file_type: 1, file_data: image.data, srv_send_msg: false },
{ msg_type: 7, media: { file_info: "FILEINFO" }, content: "", msg_id: "m1", msg_seq: 3 }
]);
} finally { globalThis.fetch = original; }
});
test("sendMessage delivers text and images to C2C with independent upload per target", async () => {
const original = globalThis.fetch; const urls: string[] = []; const bodies: Array<Record<string, unknown>> = [];
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
urls.push(url);
if (url.includes("getAppAccessToken")) return new Response(JSON.stringify({ access_token: "token", expires_in: 7200 }), { status: 200 });
bodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
if (url.endsWith("/files")) return new Response(JSON.stringify({ file_info: "FILEINFO" }), { status: 200 });
return new Response(JSON.stringify({ id: "sent" }), { status: 200 });
}) as typeof fetch;
try {
const adapter = new QqAdapter(config, { receive: async () => ({ ok: true }) } as never);
const target = { platform: "qq", chatId: "user:u2", raw: { author: { user_openid: "u2" } } };
const image = { mimeType: "image/jpeg", data: Buffer.from("fake-jpg").toString("base64") };
await adapter.sendMessage({ target, text: "看图", images: [image], replyTo: "m2", replySequence: 5 });
assert.ok(urls.some((url) => url.endsWith("/v2/users/u2/files")));
assert.equal(urls.filter((url) => url.endsWith("/v2/users/u2/messages")).length, 2);
assert.deepEqual(bodies[0], { file_type: 1, file_data: image.data, srv_send_msg: false });
assert.deepEqual(bodies[1], { msg_type: 7, media: { file_info: "FILEINFO" }, content: "", msg_id: "m2", msg_seq: 5 });
assert.deepEqual(bodies[2], { content: "看图", msg_id: "m2", msg_seq: 5 });
} finally { globalThis.fetch = original; }
});
test("image upload failures surface safe errors without the base64 payload", async () => {
const original = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request) => {
const url = String(input);
if (url.includes("getAppAccessToken")) return new Response(JSON.stringify({ access_token: "token", expires_in: 7200 }), { status: 200 });
return new Response(JSON.stringify({ code: 40034001, message: "invalid file_data" }), { status: 400 });
}) as typeof fetch;
try {
const adapter = new QqAdapter(config, { receive: async () => ({ ok: true }) } as never);
const target = { platform: "qq", chatId: "group:g1", raw: { group_openid: "g1" } };
const image = { mimeType: "image/png", data: Buffer.from("secret-image-bytes").toString("base64") };
await assert.rejects(
adapter.sendMessage({ target, text: "", images: [image], replyTo: "m1", replySequence: 1 }),
(error: Error) => {
assert.match(error.message, /QQ image upload failed: HTTP 400/);
assert.match(error.message, /code=40034001/);
assert.doesNotMatch(error.message, /secret-image-bytes/);
assert.doesNotMatch(error.message, /base64/);
return true;
}
);
} finally { globalThis.fetch = original; }
});
test("sendMessage without replyTo omits msg_id and msg_seq from the body", async () => {
const original = globalThis.fetch; const bodies: Array<Record<string, unknown>> = [];
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
if (String(input).includes("getAppAccessToken")) return new Response(JSON.stringify({ access_token: "token", expires_in: 7200 }), { status: 200 });
bodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
return new Response(JSON.stringify({ id: "sent" }), { status: 200 });
}) as typeof fetch;
try {
const adapter = new QqAdapter(config, { receive: async () => ({ ok: true }) } as never);
const target = { platform: "qq", chatId: "group:g1", raw: { group_openid: "g1" } };
await adapter.sendMessage({ target, text: "proactive" });
assert.deepEqual(bodies, [{ content: "proactive" }]);
} finally { globalThis.fetch = original; }
});
test("sendMessage surfaces safe QQ error details (HTTP status and code) without the request body", async () => {
const original = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request) => {
if (String(input).includes("getAppAccessToken")) return new Response(JSON.stringify({ access_token: "token", expires_in: 7200 }), { status: 200 });
return new Response(JSON.stringify({ code: 40034105, message: "proactive message not allowed" }), { status: 400 });
}) as typeof fetch;
try {
const adapter = new QqAdapter(config, { receive: async () => ({ ok: true }) } as never);
const target = { platform: "qq", chatId: "group:g1", raw: { group_openid: "g1" } };
await assert.rejects(
adapter.sendMessage({ target, text: "secret message content" }),
(error: Error) => {
assert.match(error.message, /HTTP 400/);
assert.match(error.message, /code=40034105/);
assert.match(error.message, /proactive message not allowed/);
assert.doesNotMatch(error.message, /secret message content/);
return true;
}
);
} finally { globalThis.fetch = original; } } finally { globalThis.fetch = original; }
}); });
+52
View File
@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseConfig } from "../src/config.js";
import { BotProfileResolver } from "../src/roles/role-registry.js";
function resolvedBot(botOverrides: Record<string, unknown> = {}) {
const config = parseConfig({
configVersion: 3,
bot: {
id: "test-bot", workspace: "/tmp", persona: "Worker 人格:严格的安全/运维边界。",
agent: { id: "kimi", command: "kimi", args: ["acp"], env: {} },
skills: [], permissions: { mode: "deny" },
...botOverrides
},
gateway: { platform: { type: "webhook", secret: "secret" } },
runtime: {}
});
return new BotProfileResolver(config).bot;
}
test("assistant uses assistantPersona while worker keeps persona", () => {
const bot = resolvedBot({ assistantPersona: "Assistant 人格:像运维老同事一样说人话。" });
assert.match(bot.assistantBootstrap, /Assistant 人格:像运维老同事一样说人话。/);
assert.doesNotMatch(bot.assistantBootstrap, /Worker 人格:严格的安全\/运维边界。/);
assert.match(bot.workerBootstrap, /Worker 人格:严格的安全\/运维边界。/);
assert.doesNotMatch(bot.workerBootstrap, /Assistant 人格/);
});
test("assistant falls back to persona when assistantPersona is empty", () => {
const bot = resolvedBot();
assert.match(bot.assistantBootstrap, /Worker 人格:严格的安全\/运维边界。/);
assert.match(bot.workerBootstrap, /Worker 人格:严格的安全\/运维边界。/);
});
test("assistant bootstrap carries human speaking-style rules, worker bootstrap does not", () => {
const bot = resolvedBot({ assistantPersona: "Assistant 人格" });
assert.match(bot.assistantBootstrap, /reliable colleague/);
assert.match(bot.assistantBootstrap, /conclusion, then the reason, then the next step/);
assert.match(bot.assistantBootstrap, /2-4 sentences/);
assert.match(bot.assistantBootstrap, /actionable next step/);
assert.match(bot.assistantBootstrap, /proposal only starts after the user confirms/i);
assert.match(bot.assistantBootstrap, /do not ask the user to re-confirm ordinary low-risk next steps/i);
assert.match(bot.workerBootstrap, /single permission grant to carry out routine low-risk execution/i);
assert.match(bot.workerBootstrap, /Do not stop for step-by-step confirmation during ordinary low-risk work/i);
assert.doesNotMatch(bot.workerBootstrap, /reliable colleague/);
});
test("assistantPersona participates in the bot fingerprint", () => {
const base = resolvedBot();
const withPersona = resolvedBot({ assistantPersona: "Assistant 人格" });
assert.notEqual(base.fingerprint, withPersona.fingerprint);
});
+82
View File
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import net from "node:net";
import path from "node:path";
import test from "node:test";
import { parseConfig } from "../src/config.js";
import { createGatewayRuntime, startServer } from "../src/server.js";
const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
test("server mounts only selected platform and exposes non-secret identity health", { concurrency: false }, async () => {
const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-server-"));
const previous = process.env.GORI_AGENT_HOME; process.env.GORI_AGENT_HOME = home;
const secret = "never-expose-this-secret";
const config = parseConfig({
configVersion: 3,
bot: { id: "route-bot", workspace: path.resolve("."), persona: "", agent: { id: "fake", command: process.execPath, args: [fixture] }, permissions: { mode: "deny" } },
gateway: { platform: { type: "webhook", secret } },
runtime: {}
});
const runtime = await createGatewayRuntime(config);
const server = runtime.app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
try {
const address = server.address(); assert.ok(address && typeof address !== "string");
const base = `http://127.0.0.1:${address.port}`;
const health = await (await fetch(`${base}/health`)).text();
assert.match(health, /"configVersion":3/); assert.match(health, /"botId":"route-bot"/); assert.match(health, /"platform":"webhook"/); assert.doesNotMatch(health, new RegExp(secret));
assert.equal((await fetch(`${base}/webhook/qq`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" })).status, 404);
assert.notEqual((await fetch(`${base}/webhook/generic`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" })).status, 404);
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
await runtime.shutdown();
if (previous === undefined) delete process.env.GORI_AGENT_HOME; else process.env.GORI_AGENT_HOME = previous;
}
});
test("QQ websocket mode does not mount the QQ webhook route", { concurrency: false }, async () => {
const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-server-qq-"));
const previous = process.env.GORI_AGENT_HOME; process.env.GORI_AGENT_HOME = home;
const config = parseConfig({
configVersion: 3,
bot: { id: "qq-bot", workspace: path.resolve("."), persona: "", agent: { id: "fake", command: process.execPath, args: [fixture] }, permissions: { mode: "deny" } },
gateway: { platform: { type: "qq", connectionMode: "websocket", appId: "id", clientSecret: "secret", botNames: ["Bot"] } },
runtime: {}
});
const runtime = await createGatewayRuntime(config);
const server = runtime.app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
try {
const address = server.address(); assert.ok(address && typeof address !== "string");
const response = await fetch(`http://127.0.0.1:${address.port}/webhook/qq`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
assert.equal(response.status, 404);
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
await runtime.shutdown();
if (previous === undefined) delete process.env.GORI_AGENT_HOME; else process.env.GORI_AGENT_HOME = previous;
}
});
test("listen failure releases the session state lock", { concurrency: false }, async () => {
const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-server-conflict-"));
const previous = process.env.GORI_AGENT_HOME; process.env.GORI_AGENT_HOME = home;
const occupied = net.createServer();
await new Promise<void>((resolve) => occupied.listen(0, "127.0.0.1", resolve));
const address = occupied.address(); assert.ok(address && typeof address !== "string");
const config = parseConfig({
configVersion: 3,
bot: { id: "conflict-bot", workspace: path.resolve("."), persona: "", agent: { id: "fake", command: process.execPath, args: [fixture] }, permissions: { mode: "deny" } },
gateway: { server: { host: "127.0.0.1", port: address.port }, platform: { type: "webhook", secret: "secret" } },
runtime: {}
});
try {
await assert.rejects(startServer(config), /EADDRINUSE/);
const runtime = await createGatewayRuntime(config);
await runtime.shutdown();
} finally {
await new Promise<void>((resolve, reject) => occupied.close((error) => error ? reject(error) : resolve()));
if (previous === undefined) delete process.env.GORI_AGENT_HOME; else process.env.GORI_AGENT_HOME = previous;
}
});
+82
View File
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
collectUsedInstancePorts,
parseServerHost,
parseServerPort,
suggestAvailablePort
} from "../src/cli/setup-server.js";
test("server host parser accepts bind addresses and hostnames", () => {
for (const host of ["0.0.0.0", "127.0.0.1", "::", "2001:db8::1", "localhost", "gateway.example.com"]) {
assert.equal(parseServerHost(host), host);
}
});
test("server host parser rejects URLs, ports, paths, whitespace, and invalid labels", () => {
for (const host of ["", "http://localhost", "localhost:8787", "[::1]", "host/path", "bad host", "-bad.example", "bad-.example"]) {
assert.throws(() => parseServerHost(host), /server host/);
}
});
test("server port parser accepts only decimal ports in range", () => {
assert.equal(parseServerPort("1"), 1);
assert.equal(parseServerPort("8787"), 8787);
assert.equal(parseServerPort("65535"), 65_535);
for (const port of ["", "0", "65536", "1.5", "0x20", "8e3", "-1"]) {
assert.throws(() => parseServerPort(port), /server port/);
}
});
test("available port suggestion advances without wrapping", () => {
assert.equal(suggestAvailablePort(8787, new Set([8787, 8788])), 8789);
assert.throws(() => suggestAvailablePort(65_535, new Set([65_535])), /No unassigned instance port/);
});
test("instance port scan reads only valid Config v3 regular files and excludes target", (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-setup-ports-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const writePeer = (name: string, value: unknown): string => {
const directory = path.join(root, name);
fs.mkdirSync(directory);
const file = path.join(directory, "config.json");
fs.writeFileSync(file, JSON.stringify(value));
return file;
};
const peer = (port: number, configVersion: number = 3) => ({
configVersion,
bot: {
id: "peer-bot",
workspace: os.tmpdir(),
persona: "peer",
agent: { id: "test", command: process.execPath, args: [], env: {} },
skills: [],
permissions: { mode: "deny", allowedTools: [], allowedCommandPatterns: [] }
},
gateway: {
server: { host: "127.0.0.1", port, publicBaseUrl: "" },
policy: { allowedUsers: [], allowedChats: [], requireMentionInGroup: true },
platform: { type: "webhook", secret: "test-secret" }
},
runtime: {}
});
writePeer("valid", peer(8787));
const excluded = writePeer("current", peer(8788));
writePeer("legacy", peer(8789, 2));
writePeer("out-of-range", peer(70_000));
writePeer("broken", "not an object");
fs.writeFileSync(path.join(root, "broken", "config.json"), "{broken");
const linkedDirectory = path.join(root, "linked");
fs.symlinkSync(path.join(root, "valid"), linkedDirectory, "dir");
const symlinkConfigDirectory = path.join(root, "symlink-config");
fs.mkdirSync(symlinkConfigDirectory);
fs.symlinkSync(path.join(root, "valid", "config.json"), path.join(symlinkConfigDirectory, "config.json"));
assert.deepEqual([...collectUsedInstancePorts(root, excluded)], [8787]);
});
+241
View File
@@ -0,0 +1,241 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import type { DiscoveredBackend } from "../src/acp/discovery.js";
import { loadConfigFile, writeConfigFile } from "../src/cli/config-file.js";
import type { Choice, PromptSession } from "../src/cli/prompt.js";
import { runSetup } from "../src/cli/setup.js";
import { parseConfig } from "../src/config.js";
const testSecret = "test-webhook-secret";
const readyAgent: DiscoveredBackend = {
id: "test-agent",
command: process.execPath,
args: ["test/fixtures/fake-acp-agent.mjs"],
status: "ready"
};
class FakePrompt implements PromptSession {
readonly defaults = new Map<string, string | undefined>();
closeCount = 0;
constructor(
private readonly answers: Record<string, string[]> = {},
private readonly secretAnswer = testSecret
) {}
async ask(question: string, defaultValue?: string): Promise<string> {
this.defaults.set(question, defaultValue);
const queued = this.answers[question];
return queued && queued.length > 0 ? queued.shift()! : defaultValue || "";
}
async askSecret(): Promise<string> { return this.secretAnswer; }
async askBoolean(_question: string, defaultValue = false): Promise<boolean> { return defaultValue; }
async askList(_question: string, defaultValues: string[] = []): Promise<string[]> { return defaultValues; }
async choose<T>(_question: string, choices: Choice<T>[], defaultIndex = 0): Promise<T> {
return (choices.find((choice) => choice.label === "Generic webhook") || choices[defaultIndex] || choices[0]).value;
}
close(): void { this.closeCount++; }
}
function peerConfig(port: number): unknown {
return {
configVersion: 3,
bot: {
id: "peer-bot",
workspace: os.tmpdir(),
persona: "peer",
agent: { id: readyAgent.id, command: readyAgent.command, args: readyAgent.args, env: {} },
skills: [],
permissions: { mode: "deny", allowedTools: [], allowedCommandPatterns: [] }
},
gateway: {
server: { host: "127.0.0.1", port, publicBaseUrl: "" },
policy: { allowedUsers: [], allowedChats: [], requireMentionInGroup: true },
platform: { type: "webhook", secret: "peer-test-secret" }
},
runtime: {}
};
}
function existingConfig(host = "0.0.0.0", port = 8787) {
return parseConfig({
configVersion: 3,
bot: {
id: "existing-bot",
workspace: os.tmpdir(),
persona: "existing persona",
agent: {
id: "custom-kimi",
command: readyAgent.command,
args: ["-m", "gori-gpt/gpt-5.5", "acp"],
env: { TEST_AGENT_SETTING: "preserved" }
},
skills: [{ id: "test-skill", file: path.join(os.tmpdir(), "test-skill.md"), maxBytes: 1234 }],
permissions: { mode: "allowlist", allowedTools: ["Read"], allowedCommandPatterns: ["^true$"] }
},
gateway: {
server: { host, port, publicBaseUrl: "https://public.example.test" },
policy: { allowedUsers: ["allowed-user"], allowedChats: ["allowed-chat"], requireMentionInGroup: false },
platform: { type: "webhook", secret: testSecret }
},
runtime: { acp: { promptTimeoutMs: 123456, maxProcesses: 3 } }
});
}
test("new setup suggests the next unassigned instance port", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-setup-new-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
for (const [name, port] of [["peer-one", 8787], ["peer-two", 8788]] as const) {
const directory = path.join(root, name);
fs.mkdirSync(directory);
fs.writeFileSync(path.join(directory, "config.json"), JSON.stringify(peerConfig(port)));
}
const target = path.join(root, "new-bot", "config.json");
const prompt = new FakePrompt();
const messages: string[] = [];
await runSetup(target, {
botId: "new-bot",
requireNew: true,
writeWithoutConfirmation: true,
prompt,
discoverAgents: async () => [readyAgent],
instancesDirectory: root,
log: (message) => messages.push(message)
});
const config = loadConfigFile(target).config;
assert.equal(prompt.defaults.get("Gateway server port"), "8789");
assert.equal(config.gateway.server.port, 8789);
assert.equal(config.gateway.server.host, "0.0.0.0");
assert.equal(fs.statSync(target).mode & 0o777, 0o600);
assert.equal(prompt.closeCount, 1);
assert.ok(messages.some((message) => message.includes("suggested port: 8789")));
assert.ok(messages.every((message) => !message.includes(testSecret)));
});
test("existing setup can change host and port while preserving public URL and secret", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-setup-existing-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const target = path.join(root, "existing-bot", "config.json");
writeConfigFile(target, existingConfig());
const peerDirectory = path.join(root, "peer");
fs.mkdirSync(peerDirectory);
fs.writeFileSync(path.join(peerDirectory, "config.json"), JSON.stringify(peerConfig(8787)));
const prompt = new FakePrompt({
"Gateway server host": ["http://bad-host", "127.0.0.1"],
"Gateway server port": ["not-a-port", "9001"]
}, "");
const messages: string[] = [];
await runSetup(target, {
botId: "existing-bot",
writeWithoutConfirmation: true,
prompt,
discoverAgents: async () => [readyAgent],
instancesDirectory: root,
log: (message) => messages.push(message)
});
const config = loadConfigFile(target).config;
assert.equal(prompt.defaults.get("Gateway server port"), "8787");
assert.equal(config.gateway.server.host, "127.0.0.1");
assert.equal(config.gateway.server.port, 9001);
assert.equal(config.gateway.server.publicBaseUrl, "https://public.example.test");
assert.equal(config.gateway.platform.type, "webhook");
assert.equal(config.gateway.platform.secret, testSecret);
assert.deepEqual(config.bot.agent, {
id: "custom-kimi",
command: readyAgent.command,
args: ["-m", "gori-gpt/gpt-5.5", "acp"],
env: { TEST_AGENT_SETTING: "preserved" }
});
assert.deepEqual(config.bot.skills, [{ id: "test-skill", file: path.join(os.tmpdir(), "test-skill.md"), maxBytes: 1234 }]);
assert.deepEqual(config.bot.permissions, { mode: "allowlist", allowedTools: ["Read"], allowedCommandPatterns: ["^true$"] });
assert.deepEqual(config.gateway.policy, { allowedUsers: ["allowed-user"], allowedChats: ["allowed-chat"], requireMentionInGroup: false });
assert.equal(config.runtime.acp.promptTimeoutMs, 123456);
assert.equal(config.runtime.acp.maxProcesses, 3);
assert.equal(prompt.closeCount, 1);
assert.ok(messages.some((message) => message.startsWith("ERROR: server host")));
assert.ok(messages.some((message) => message.startsWith("ERROR: server port")));
assert.ok(messages.some((message) => message.includes("suggested port: 8788")));
assert.ok(messages.every((message) => !message.includes(testSecret)));
});
test("existing setup keeps host and port when defaults are accepted", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-setup-keep-server-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const target = path.join(root, "existing-bot", "config.json");
writeConfigFile(target, existingConfig("localhost", 9123));
const prompt = new FakePrompt({}, "");
await runSetup(target, {
botId: "existing-bot",
writeWithoutConfirmation: true,
prompt,
discoverAgents: async () => [readyAgent],
instancesDirectory: root,
log: () => undefined
});
const config = loadConfigFile(target).config;
assert.equal(prompt.defaults.get("Gateway server host"), "localhost");
assert.equal(prompt.defaults.get("Gateway server port"), "9123");
assert.equal(config.gateway.server.host, "localhost");
assert.equal(config.gateway.server.port, 9123);
assert.equal(prompt.closeCount, 1);
});
test("existing setup succeeds without a ready discovered agent and preserves its agent", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-setup-no-ready-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const target = path.join(root, "existing-bot", "config.json");
writeConfigFile(target, existingConfig());
await runSetup(target, {
botId: "existing-bot",
writeWithoutConfirmation: true,
prompt: new FakePrompt({}, ""),
discoverAgents: async () => [{ id: "kimi", command: "kimi", args: ["acp"], status: "not-found" }],
instancesDirectory: root,
log: () => undefined
});
assert.deepEqual(loadConfigFile(target).config.bot.agent, existingConfig().bot.agent);
});
test("new setup still fails when no ACP agent is ready", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-setup-new-no-ready-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const prompt = new FakePrompt();
await assert.rejects(runSetup(path.join(root, "new-bot", "config.json"), {
botId: "new-bot",
requireNew: true,
writeWithoutConfirmation: true,
prompt,
discoverAgents: async () => [],
instancesDirectory: root,
log: () => undefined
}), /No ACP agent is available/);
assert.equal(prompt.closeCount, 1);
});
test("existing setup rejects attempts to change bot identity", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-setup-identity-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const target = path.join(root, "existing-bot", "config.json");
writeConfigFile(target, existingConfig());
await assert.rejects(runSetup(target, {
botId: "different-bot",
prompt: new FakePrompt(),
discoverAgents: async () => []
}), /Cannot change existing bot\.id/);
});
+102
View File
@@ -0,0 +1,102 @@
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 { canonicalWorkspace, findOverlappingWorkspace } from "../src/core/workspace-scope.js";
function writePeer(instances: string, botId: string, workspace: string): void {
const directory = path.join(instances, botId);
fs.mkdirSync(directory, { recursive: true });
fs.writeFileSync(path.join(directory, "config.json"), JSON.stringify({
configVersion: 3,
bot: {
id: botId,
workspace,
persona: "test",
agent: { id: "fake", command: process.execPath, args: ["agent.mjs"], env: {} },
skills: [],
permissions: { mode: "deny", allowedTools: [], allowedCommandPatterns: [] }
},
gateway: { platform: { type: "webhook", secret: "test-secret" } },
runtime: { acp: {} }
}));
}
test("peer scan rejects identical, parent, and child workspaces and allows disjoint scopes", async (t) => {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-workspace-scope-"));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const instances = path.join(directory, "instances");
const workspace = path.join(directory, "workspace");
const child = path.join(workspace, "child");
const sibling = path.join(directory, "sibling");
fs.mkdirSync(child, { recursive: true });
fs.mkdirSync(sibling, { recursive: true });
// Disjoint workspaces are allowed.
writePeer(instances, "sibling-bot", sibling);
assert.equal(findOverlappingWorkspace(workspace, "current", instances), undefined);
// An identical workspace is rejected.
writePeer(instances, "same-bot", workspace);
assert.equal(findOverlappingWorkspace(workspace, "current", instances), "same-bot");
// A child workspace is rejected from the parent's perspective.
fs.rmSync(path.join(instances, "same-bot"), { recursive: true, force: true });
writePeer(instances, "child-bot", child);
assert.equal(findOverlappingWorkspace(workspace, "current", instances), "child-bot");
// From the child's perspective an identical or parent peer workspace is rejected.
assert.equal(findOverlappingWorkspace(child, "current", instances), "child-bot");
writePeer(instances, "same-bot", workspace);
assert.ok(["same-bot", "child-bot"].includes(findOverlappingWorkspace(child, "current", instances)!));
// The instance's own entry is skipped even when its workspace matches.
fs.rmSync(path.join(instances, "child-bot"), { recursive: true, force: true });
assert.equal(findOverlappingWorkspace(workspace, "same-bot", instances), undefined);
// A missing instances directory means no peers to conflict with.
assert.equal(findOverlappingWorkspace(workspace, "current", path.join(directory, "absent")), undefined);
});
test("peer scan fails closed for unsafe or invalid peer configs", async (t) => {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-workspace-scope-closed-"));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const instances = path.join(directory, "instances");
const workspace = path.join(directory, "workspace");
fs.mkdirSync(workspace, { recursive: true });
// Peer config declares a workspace that cannot be canonicalized.
writePeer(instances, "missing-bot", path.join(directory, "missing"));
assert.throws(() => findOverlappingWorkspace(workspace, "current", instances));
fs.rmSync(path.join(instances, "missing-bot"), { recursive: true, force: true });
// Corrupt peer config fails closed.
const corrupt = path.join(instances, "corrupt-bot");
fs.mkdirSync(corrupt);
fs.writeFileSync(path.join(corrupt, "config.json"), "not-json");
assert.throws(() => findOverlappingWorkspace(workspace, "current", instances), /Invalid peer Config v3/);
fs.rmSync(path.join(corrupt, "config.json"));
// Missing peer config fails closed.
assert.throws(() => findOverlappingWorkspace(workspace, "current", instances), /missing config/);
fs.rmSync(corrupt, { recursive: true, force: true });
// Symlinked peer entries are refused.
writePeer(instances, "real-bot", path.join(directory, "sibling-real"));
fs.mkdirSync(path.join(directory, "sibling-real"), { recursive: true });
fs.symlinkSync(path.join(instances, "real-bot"), path.join(instances, "linked-bot"));
assert.throws(() => findOverlappingWorkspace(workspace, "current", instances), /unsafe peer instance entry/);
});
test("canonicalWorkspace resolves symlinks and relative segments", async (t) => {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-workspace-canonical-"));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const real = path.join(directory, "real");
fs.mkdirSync(real, { recursive: true });
const link = path.join(directory, "link");
fs.symlinkSync(real, link);
assert.equal(canonicalWorkspace(link), fs.realpathSync.native(real));
assert.equal(canonicalWorkspace(path.join(real, "..", "real")), fs.realpathSync.native(real));
assert.throws(() => canonicalWorkspace(path.join(directory, "missing")));
});