Compare commits
19 Commits
fe7bbd0631
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e9f1d595fa | |||
| 9ae2639660 | |||
| 5229059fe3 | |||
| 91327dc632 | |||
| f17fee7383 | |||
| 4f7b4a9115 | |||
| bd96595ba8 | |||
| 9c3139380d | |||
| 6b506b8c55 | |||
| 7aa610294c | |||
| 33854721bf | |||
| 2409922d55 | |||
| ffedf616e3 | |||
| 4af5049605 | |||
| b4121c9fd6 | |||
| 947f19e3cd | |||
| 680a449cd8 | |||
| 2795567f8c | |||
| 91bf4e7a0c |
@@ -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
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
`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 → AcpSessionManager → AcpWorker → ACP Agent
|
用户 → Platform Adapter → Gateway → AssistantManager(Assistant / Proposal / Worker)→ ACP Agent
|
||||||
```
|
```
|
||||||
|
|
||||||
每个运行实例是一个完整且固定的 Bot:
|
每个运行实例是一个完整且固定的 Bot:
|
||||||
@@ -32,6 +32,9 @@ ${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances/<bot-id>/
|
|||||||
├── logs/gori-agent.log
|
├── logs/gori-agent.log
|
||||||
└── state/
|
└── state/
|
||||||
├── acp-sessions.json
|
├── acp-sessions.json
|
||||||
|
├── proposals.json
|
||||||
|
├── assistant-workspaces/
|
||||||
|
├── kimi/assistant/
|
||||||
└── gori-agent.pid
|
└── gori-agent.pid
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -42,6 +45,7 @@ ${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances/<bot-id>/
|
|||||||
- 实例目录、`logs/`、`state/` 为 `0700`。
|
- 实例目录、`logs/`、`state/` 为 `0700`。
|
||||||
- `config.json`、PID、state 为 `0600`。
|
- `config.json`、PID、state 为 `0600`。
|
||||||
- 多实例必须使用不同 `gateway.server.port` 和平台 credentials。
|
- 多实例必须使用不同 `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 的声明端口,冲突时警告,新实例建议下一个未声明端口,已有配置不得静默改端口。
|
- `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 <bot-id>` 只重配已有实例:校验目录/config/PID,运行中或 foreign PID 拒绝,固定 Bot ID,保留 agent/skills/permissions/policy/runtime/secrets/publicBaseUrl,确认写入后运行 doctor。
|
||||||
- setup 的声明冲突提示不替代实际 bind 检查;`start` 对缺配置、ID 不匹配、错误权限、占位符、运行 PID、端口冲突 fail closed。
|
- setup 的声明冲突提示不替代实际 bind 检查;`start` 对缺配置、ID 不匹配、错误权限、占位符、运行 PID、端口冲突 fail closed。
|
||||||
@@ -69,25 +73,39 @@ gori-agent list
|
|||||||
- Config v3 Zod schema、类型、交叉校验和 state 默认路径。
|
- Config v3 Zod schema、类型、交叉校验和 state 默认路径。
|
||||||
- 只接受 `configVersion: 3`。
|
- 只接受 `configVersion: 3`。
|
||||||
- `src/server.ts`
|
- `src/server.ts`
|
||||||
- 组装 fixed Bot、state store、ACP manager、Gateway 和唯一 platform adapter。
|
- 组装 fixed Bot、state store、ProposalStore、AssistantManager、Gateway 和唯一 platform adapter。
|
||||||
- 只挂载所选平台 route。
|
- 只挂载所选平台 route;创建 AssistantManager 时传入 `runtime.acp.maxAssistantSessions`。
|
||||||
- `src/roles/role-registry.ts`
|
- `src/roles/role-registry.ts`
|
||||||
- 历史路径名保留,但实现是 `BotProfileResolver`,不是 role registry。
|
- 历史路径名保留,但实现是 `BotProfileResolver`,不是 role registry。
|
||||||
- 加载当前 Bot skills、计算 fingerprint、生成版本化 bootstrap。
|
- 加载当前 Bot skills、计算 fingerprint、生成版本化 assistant/worker bootstrap。
|
||||||
|
- Assistant 用 `bot.assistantPersona || bot.persona` 并带人话风格规则;Worker 始终用 `bot.persona`,bootstrap 保持严格。
|
||||||
- `src/acp/client.ts`
|
- `src/acp/client.ts`
|
||||||
- ACP initialize/new/resume/load/prompt/cancel 和 permission request。
|
- ACP initialize/new/resume/load/prompt/cancel 和 permission request。
|
||||||
|
- assistant session 一旦出现 tool update 或 permission request 必须 fail closed。
|
||||||
- `src/acp/worker.ts`
|
- `src/acp/worker.ts`
|
||||||
- 直接使用 resolved Bot 的 agent,在 `bot.workspace` 启动。
|
- worker 在 `bot.workspace` 启动;assistant 在实例私有 `state/assistant-workspaces/<hmac>` 启动,未显式配置时 `KIMI_CODE_HOME` 指向实例私有 `state/kimi/assistant/`。
|
||||||
- `src/acp/session-manager.ts`
|
- 每个 ACP worker 使用独立进程组和随机 `GORI_AGENT_WORKER_TOKEN`;cancel、timeout、crash 或 assistant 隔离违约必须清理同进程组工具后代;bootstrap 禁止 `setsid`、`nohup`、detached/daemon/background 遗留进程,主动脱组属于无 cgroup/Bubblewrap 方案的边界。
|
||||||
- 固定 Bot 的 binding、worker pool、恢复、idle sweep、capacity、cancel/reset。
|
- 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`
|
- `src/core/durable-session-store.ts`
|
||||||
- state v2,保存 bot/platform identity 和 chat binding。
|
- state v3 只保存 bot/platform identity 和 assistant binding(conversation key,即 chat + user、agent ID、native session ID、assistant workspace、fingerprint、时间戳、`lastActiveAt`);不保存消息正文。
|
||||||
- 单 writer lock、串行持久化、临时文件、fsync、原子 rename。
|
- 单 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`
|
- `src/core/gateway.ts`
|
||||||
- 入站 allowlist、群 mention、命令、per-chat lock 和回复。
|
- 入站 allowlist、群 mention、命令和回复;不维护普通消息队列或 per-chat task lock,也没有固定时间处理中提醒。
|
||||||
- `/cancel` 必须绕过 chat 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`
|
- `src/core/command-router.ts`
|
||||||
- `/help`、`/status`、`/cancel`、`/new`;旧 role 命令返回 retired 提示。
|
- `/help`、`/status`、`/list`、`/confirm`、`/finish`、`/stop`、`/cancel`;未识别命令按普通消息处理。
|
||||||
- `src/platforms/*`
|
- `src/platforms/*`
|
||||||
- Adapter 依赖独立平台 config type,不依赖完整 AppConfig 路径。
|
- Adapter 依赖独立平台 config type,不依赖完整 AppConfig 路径。
|
||||||
- `src/cli/config-file.ts`
|
- `src/cli/config-file.ts`
|
||||||
@@ -123,6 +141,7 @@ runtime.acp state 和 ACP 生命周期
|
|||||||
"id": "bot-id",
|
"id": "bot-id",
|
||||||
"workspace": "/absolute/path",
|
"workspace": "/absolute/path",
|
||||||
"persona": "明确职责、边界和确认点。",
|
"persona": "明确职责、边界和确认点。",
|
||||||
|
"assistantPersona": "可选;Assistant 讲话人格,为空时回退 persona。Worker 始终用 persona。",
|
||||||
"agent": {
|
"agent": {
|
||||||
"id": "kimi",
|
"id": "kimi",
|
||||||
"command": "/absolute/path/to/kimi",
|
"command": "/absolute/path/to/kimi",
|
||||||
@@ -170,36 +189,47 @@ permission policy 只审批 ACP request,不会注册工具。persona 不是安
|
|||||||
- `gateway.platform`: `type` discriminated union,只能是 qq、feishu、wecom、webhook、weixin 之一。
|
- `gateway.platform`: `type` discriminated union,只能是 qq、feishu、wecom、webhook、weixin 之一。
|
||||||
- 对象存在即启用,无 `enabled` 字段。
|
- 对象存在即启用,无 `enabled` 字段。
|
||||||
|
|
||||||
QQ 群 chat ID 为 `group:<group_openid>`。正式运维 Bot 应配置入口 allowlist。
|
QQ 群 chat ID 为 `group:<group_openid>`;用户 ID 按 QQ 官方语义取值:群聊作者用 `member_openid`,C2C 私聊作者用 `user_openid`。正式运维 Bot 应配置入口 allowlist。
|
||||||
|
|
||||||
### 4.5 Runtime ACP
|
### 4.5 Runtime ACP
|
||||||
|
|
||||||
- `stateFile` 为空时为 `$GORI_AGENT_HOME/state/acp-sessions.json`。
|
- `stateFile` 为空时为 `$GORI_AGENT_HOME/state/acp-sessions.json`;proposals 固定在同目录 `proposals.json`。
|
||||||
- `initializeTimeoutMs`: 默认 10000。
|
- `initializeTimeoutMs`: 默认 10000。
|
||||||
- `promptTimeoutMs`: 默认 7200000(2 小时)。
|
- `promptTimeoutMs`: 默认 14400000(4 小时)。
|
||||||
- `cancelGraceMs`: 默认 5000。
|
- `cancelGraceMs`: 默认 5000。
|
||||||
- `idleTimeoutMs`: 默认 1800000。
|
- `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。
|
- `sweepIntervalMs`: 默认 60000。
|
||||||
- `maxProcesses`: 默认 8。
|
- `maxProcesses`: 默认 8,包含 assistant + worker。
|
||||||
|
- `maxAssistantSessions`: 默认 4。
|
||||||
|
|
||||||
## 5. Session 与 state v2
|
Kimi Code agent `args` 必须严格为 `["acp"]`,不能添加可能绕过 assistant no-tool profile 的启动参数。
|
||||||
|
|
||||||
Binding key 是当前实例固定的 `platform + chatId`。不再包含 role。
|
## 5. Assistant / Proposal / Worker 与 state v3
|
||||||
|
|
||||||
state v2 header 保存 `botId` 和 platform。打开时不匹配必须拒绝,不能清空、迁移或覆盖。旧 state v1 也明确拒绝并保留原文件。
|
运行链路是三层:每 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。
|
||||||
|
|
||||||
Binding 保存 agent ID、workspace、native session ID、Bot fingerprint 和时间戳。fingerprint 包含:
|
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 开始。
|
||||||
|
|
||||||
- bootstrap schema version
|
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(字符串/转义感知,配平点必须在文本末尾),失败才进入修复流程。
|
||||||
- Bot ID
|
|
||||||
- workspace/persona
|
|
||||||
- agent 定义
|
|
||||||
- permission policy
|
|
||||||
- skill 路径和内容 hash
|
|
||||||
|
|
||||||
首次消息创建 native session,发送隐藏 bootstrap,成功后保存 binding。重启/idle 后优先 resume,再 fallback load。prompt 失败不重放。
|
确认语义:
|
||||||
|
|
||||||
`/new` 删除当前 chat binding;下一条消息创建新 session。`/cancel` 不等待 chat lock。
|
- Worker 落定即进入 `pending`(记录 summary/question?/workspaceDirty?/receivedAt);`finish`(可带 note)落定为 `finished(done)`,`cancel` 落定为 `finished(cancelled)`;finished 保留最后 summary 供面板展示。
|
||||||
|
- 系统不自动开始下一个 Proposal;只有新确认或 `start_next` 推进队列。
|
||||||
|
- 阻塞规则:任何 `working` 全局拒绝 start_next/follow_up;owner 自己有 pending 时拒绝该 owner 的 start_next(提示先 finish 或 follow_up);任何 `workspaceDirty: true` 的 pending 全局拒绝 start_next(提示先处理 dirty 工作区);非 dirty 的 pending 不挡其他用户。
|
||||||
|
- `stop` 只对 working 生效:停 worker(现有进程组清理)后 Proposal 转为 pending(summary「被用户中止」、workspaceDirty)。
|
||||||
|
- Assistant 有 pending 时每轮 prompt 注入 pending card;回复没提到 pending 时 runtime 在 reply 末尾追加人话兜底提醒。
|
||||||
|
- Worker working 期间 runtime 按类别聚合 worker 的 `session/update` 工具活动(turn 开始重置计数;只留类别/时间,不留参数与正文);Assistant prompt 注入紧凑状态卡(title、mm:ss 时长、最后活动类别与距今、各类别计数、快照指引)。调试期状态卡对所有人可见(他人任务标注 `another user's proposal`),正式运营时应恢复 owner-only。
|
||||||
|
- Gateway 无固定时间提醒;worker 落定后由 Assistant 生成事件文案发给 owner chat,QQ 无主动消息权限时优先使用被动回复窗口(群 5 分钟/私聊 60 分钟,按 4.5/55 分钟保守判定),过期或失败则不主动发、记录 lastEventDelivery 并在下次入站时补发;`/status` 输出当前 chat 的事件投递状态与 schedulerState/blockedReason/nextAction。
|
||||||
|
|
||||||
|
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)。
|
||||||
|
|
||||||
|
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 失败不重放。
|
||||||
|
|
||||||
|
命令 `/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>` 文本拼接。
|
||||||
|
|
||||||
|
出站图片(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` 标记时图片降级为 `[图片] <文件名>` 文本。
|
||||||
|
|
||||||
## 6. 单平台装配
|
## 6. 单平台装配
|
||||||
|
|
||||||
@@ -229,6 +259,8 @@ QQ WebSocket 仅在 qq + websocket 模式启动。`GET /health` 只输出 config
|
|||||||
|
|
||||||
## 8. 测试与验收
|
## 8. 测试与验收
|
||||||
|
|
||||||
|
项目级部署/配置 skill 位于 `.kimi-code/skills/gori-agent-deploy/SKILL.md`;真实 Bot 部署、模型/persona 配置和 state 备份流程以该文件为准。
|
||||||
|
|
||||||
行为变化补测试,不削弱测试。配置 schema 变化同步:
|
行为变化补测试,不削弱测试。配置 schema 变化同步:
|
||||||
|
|
||||||
- `src/config.ts`
|
- `src/config.ts`
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
`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
|
||||||
用户 → 单个平台 Adapter → Gateway → ACP Session Manager → 单个 ACP Agent
|
用户 → 单个平台 Adapter → Gateway → AssistantManager(Assistant / Proposal / Worker)→ 单个 ACP Agent
|
||||||
```
|
```
|
||||||
|
|
||||||
## Config v3 实例模型
|
## Config v3 实例模型
|
||||||
@@ -12,9 +12,11 @@ Config v3 只支持以下边界:
|
|||||||
|
|
||||||
- 一份运行配置对应一个固定 Bot 身份。
|
- 一份运行配置对应一个固定 Bot 身份。
|
||||||
- 一个 Bot 只有一个 workspace、persona、ACP agent、skill 列表和 permission policy。
|
- 一个 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` 切换或 Config v1/v2 迁移。
|
- 不再有 `roles[]`、`defaultRole`、`backends[]`、动态 `/role` 切换、单主任务/近似 BTW 或 Config v1/v2 迁移。
|
||||||
- `configVersion` 非 `3` 会明确失败,旧 state v1 也不会自动改写。
|
- `configVersion` 非 `3` 会明确失败,旧 state v1/v2 也不会自动改写。
|
||||||
|
|
||||||
一台机器上的实例统一位于:
|
一台机器上的实例统一位于:
|
||||||
|
|
||||||
@@ -23,7 +25,10 @@ ${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances/<bot-id>/
|
|||||||
├── config.json
|
├── config.json
|
||||||
├── logs/gori-agent.log
|
├── logs/gori-agent.log
|
||||||
└── state/
|
└── state/
|
||||||
├── acp-sessions.json
|
├── 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.pid
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -91,7 +96,7 @@ GORI_AGENT_ROOT=/srv/gori-agent gori-agent list
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
configVersion 固定为 3
|
configVersion 固定为 3
|
||||||
bot Bot、workspace、persona、agent、skills、permissions
|
bot Bot、workspace、persona、assistantPersona、agent、skills、permissions
|
||||||
gateway HTTP server、入站 policy、唯一 platform
|
gateway HTTP server、入站 policy、唯一 platform
|
||||||
runtime.acp ACP state、timeout 和 worker pool
|
runtime.acp ACP state、timeout 和 worker pool
|
||||||
```
|
```
|
||||||
@@ -104,6 +109,7 @@ runtime.acp ACP state、timeout 和 worker pool
|
|||||||
"id": "my-bot",
|
"id": "my-bot",
|
||||||
"workspace": "/absolute/workspace",
|
"workspace": "/absolute/workspace",
|
||||||
"persona": "Describe responsibilities, boundaries, and confirmation points.",
|
"persona": "Describe responsibilities, boundaries, and confirmation points.",
|
||||||
|
"assistantPersona": "Optional speaking personality for the Assistant; falls back to persona when empty.",
|
||||||
"agent": {
|
"agent": {
|
||||||
"id": "kimi",
|
"id": "kimi",
|
||||||
"command": "/home/USER/.kimi-code/bin/kimi",
|
"command": "/home/USER/.kimi-code/bin/kimi",
|
||||||
@@ -122,6 +128,7 @@ runtime.acp ACP state、timeout 和 worker pool
|
|||||||
|
|
||||||
- `bot.id` 只允许小写字母、数字和连字符,最长 63 字符。
|
- `bot.id` 只允许小写字母、数字和连字符,最长 63 字符。
|
||||||
- `workspace` 与每个 skill `file` 必须是绝对路径。
|
- `workspace` 与每个 skill `file` 必须是绝对路径。
|
||||||
|
- Worker bootstrap 始终使用 `bot.persona`;Assistant 使用 `bot.assistantPersona`,为空时回退到 `bot.persona`。`assistantPersona` 只决定 Assistant 的讲话人格,可让运维 Bot 的 Assistant 说人话而 Worker 保持严格。
|
||||||
- `bot.skills[]` 直接声明 `{ id, file, maxBytes }`;ID 必须唯一。
|
- `bot.skills[]` 直接声明 `{ id, file, maxBytes }`;ID 必须唯一。
|
||||||
- agent 使用 `shell: false` 在 `bot.workspace` 启动。
|
- agent 使用 `shell: false` 在 `bot.workspace` 启动。
|
||||||
- agent env 可能包含 secret,不能进入可提交模板、日志或 diff。
|
- agent env 可能包含 secret,不能进入可提交模板、日志或 diff。
|
||||||
@@ -164,54 +171,85 @@ QQ websocket 示例:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
正式 Bot 应通过 `gateway.policy.allowedUsers` / `allowedChats` 限制入口。QQ 群 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。
|
||||||
|
|
||||||
|
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 不支持图片时把图片降级为 `[图片] <文件名>` 文本行。
|
||||||
|
|
||||||
### ACP 生命周期
|
### ACP 生命周期
|
||||||
|
|
||||||
默认 `runtime.acp.promptTimeoutMs` 是 `7200000`(2 小时),适合长构建/部署任务。其他默认值:
|
默认 `runtime.acp.promptTimeoutMs` 是 `14400000`(4 小时),适合长构建/部署任务。其他默认值:
|
||||||
|
|
||||||
- initialize:10 秒
|
- initialize:10 秒
|
||||||
- cancel grace:5 秒
|
- cancel grace:5 秒
|
||||||
- idle worker:30 分钟
|
- idle assistant:30 分钟
|
||||||
|
- assistant session reset idle:1 小时(`assistantSessionResetIdleMs`,`0` 禁用;Assistant 会话空闲超过该阈值且 owner 没有未完成 Proposal 时,下一条消息开新 session 而不是 resume)
|
||||||
- sweep:60 秒
|
- sweep:60 秒
|
||||||
- max processes:8
|
- max processes:8(Assistant + Worker 总和)
|
||||||
|
- max assistant sessions:4
|
||||||
|
|
||||||
`stateFile` 为空时使用当前实例的 `$GORI_AGENT_HOME/state/acp-sessions.json`。
|
`stateFile` 为空时使用当前实例的 `$GORI_AGENT_HOME/state/acp-sessions.json`;Proposal 记录固定保存在同目录的 `proposals.json`。Kimi Code agent 的 `args` 必须严格为 `["acp"]`,避免额外启动参数绕过 Assistant 的 no-tool profile。
|
||||||
|
|
||||||
## Session 与 state v2
|
## Assistant / Proposal / Worker 与 state v3
|
||||||
|
|
||||||
每个绑定由当前实例固定的 `platform + chatId` 唯一确定。state v2 header 保存 `botId` 与 platform type;打开 state 时身份不匹配会拒绝启动,避免错误复用另一个实例目录。
|
运行链路是三层:
|
||||||
|
|
||||||
Binding 保存:
|
- **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,违规的丢弃并在事件文本里说明。
|
||||||
|
|
||||||
- chat key
|
确认语义是刻意的:
|
||||||
- agent ID
|
|
||||||
- native ACP session ID
|
|
||||||
- workspace
|
|
||||||
- Bot fingerprint
|
|
||||||
- 创建与更新时间
|
|
||||||
|
|
||||||
Bot fingerprint 包含 Bot ID、workspace、persona、agent、permissions、skill 路径/内容 hash 和 bootstrap schema version。任一语义变化后,下一条消息创建新 native session,不恢复旧身份上下文。
|
- 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 同样是新消息,不引用原消息)。
|
||||||
|
|
||||||
首次 prompt 会创建 native session,发送隐藏 bootstrap,bootstrap 成功后才保存 binding。worker 空闲回收后优先 `session/resume`,否则回退 `session/load`。失败 prompt 不会自动重放,因为工具操作可能已有副作用。
|
Assistant 隔离与旧 side Session 一致且更严格:
|
||||||
|
|
||||||
旧 state v1 会被原样保留并明确拒绝;使用新的实例目录开始 state v2,不要手工改写运行中的 state。
|
- 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 命令
|
## IM 命令
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/help
|
/help
|
||||||
/status
|
/status
|
||||||
|
/list
|
||||||
|
/confirm
|
||||||
|
/finish
|
||||||
|
/stop
|
||||||
/cancel
|
/cancel
|
||||||
/new
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- `/status` 显示固定 Bot、agent、workspace 和 session persisted/running 状态。
|
- `/help`:显示自然语言使用说明和兜底命令列表。
|
||||||
- `/cancel` 绕过 per-chat lock,发送 ACP cancel。
|
- `/status`:显示固定 Bot、agent、workspace、Assistant 会话数、各状态 Proposal 计数、Worker 是否在执行及当前用户是否 owner;另含当前用户维度(`myQueuedProposals`、`myPendingProposals`、`schedulerState`、`blockedReason`、`nextAction`,他人 Proposal 只给脱敏原因,不暴露 title/id)与当前 chat 的事件投递状态(`lastEventDelivery`、`lastEventError`、`lastEventAt`)。
|
||||||
- `/new` 清除当前 chat binding;下一条普通消息创建新 native session。
|
- `/list`:全局 Proposal 板面板,按 待确认(pending 优先)→ 进行中 → 排队中 → 未确认(proposed)→ 最近结束 排列;自己的条目标注「你的」,他人的标注「其他成员」(不暴露 chat/user ID);最近结束也全局显示。
|
||||||
- `/roles`、`/role`、`/agents`、`/agent` 会返回固定 retired 提示,不会转发给 ACP。
|
- `/confirm`:确认当前用户最近待确认的 `proposed` Proposal(进入队列)。
|
||||||
|
- `/finish`:把当前用户最近的 `pending` Proposal 落定为 `finished(done)`。
|
||||||
|
- `/stop`:停止当前用户正在执行的 Worker,Proposal 转为 `pending`(被用户中止、dirty);只有 Proposal 发起人可用。
|
||||||
|
- `/cancel`:取消当前用户最近的 `proposed` / `queued` / `pending` Proposal,落定为 `finished(cancelled)`。
|
||||||
|
|
||||||
同一 chat 串行执行,不同 chat 可并发。
|
日常操作以自然语言为主,Assistant 会自己生成 confirm/follow_up/finish/cancel/stop 等 action;这些命令是旁路 Assistant 的兜底入口。未识别的 `/` 命令按普通消息交给 Assistant。
|
||||||
|
|
||||||
|
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),不影响任务或后续发送。
|
||||||
|
|
||||||
|
每条异步入站使用独立、串行的回复流,`replySequence` 从 1 动态递增;同步 webhook 直接返回 JSON 结果。
|
||||||
|
|
||||||
## HTTP 端点
|
## HTTP 端点
|
||||||
|
|
||||||
@@ -225,7 +263,8 @@ Bot fingerprint 包含 Bot ID、workspace、persona、agent、permissions、skil
|
|||||||
|
|
||||||
- Config v3 与 example placeholder。
|
- Config v3 与 example placeholder。
|
||||||
- 配置权限 `0600`。
|
- 配置权限 `0600`。
|
||||||
- Bot ID、workspace、skills 与 permissions。
|
- Bot ID、workspace、skills、permissions,以及 agent `args` 严格为 `["acp"]`。
|
||||||
|
- 同一实例根下相同或 parent/child workspace 重叠。
|
||||||
- ACP initialize 与 session restore capability。
|
- ACP initialize 与 session restore capability。
|
||||||
- state 目录可读写。
|
- state 目录可读写。
|
||||||
- 单平台必需字段,但不打印 credential 值。
|
- 单平台必需字段,但不打印 credential 值。
|
||||||
@@ -234,6 +273,8 @@ Bot fingerprint 包含 Bot ID、workspace、persona、agent、permissions、skil
|
|||||||
|
|
||||||
## 开发验收
|
## 开发验收
|
||||||
|
|
||||||
|
项目级部署/配置 skill 位于 `.kimi-code/skills/gori-agent-deploy/SKILL.md`,覆盖代码发布、实例模型/人格配置、state 备份和 QQ 事件投递边界。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
npm test
|
npm test
|
||||||
@@ -242,6 +283,6 @@ git diff --check
|
|||||||
bash -n install.sh gori-agent.sh bin/gori-agent
|
bash -n install.sh gori-agent.sh bin/gori-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
测试使用 Node `node:test` + `tsx` 和本地 fake ACP 子进程,覆盖 Config v3、permission、bootstrap、timeout/cancel、冷恢复、fingerprint mismatch、state v2 identity、原子配置/state 写入、Gateway retired commands、QQ normalization 和单平台 route。
|
测试使用 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,不读取或输出真实配置、凭据、日志正文。
|
||||||
|
|
||||||
`src/agents/*` 与 `src/core/session-store.ts` 仅为 legacy compatibility/reference,不在当前运行链路。运行时没有单轮 CLI fallback。
|
`src/agents/*` 与 `src/core/session-store.ts` 仅为 legacy compatibility/reference,不在当前运行链路。运行时没有单轮 CLI fallback。
|
||||||
|
|||||||
+5
-2
@@ -4,6 +4,7 @@
|
|||||||
"id": "BOT_ID",
|
"id": "BOT_ID",
|
||||||
"workspace": "/absolute/path/to/workspace",
|
"workspace": "/absolute/path/to/workspace",
|
||||||
"persona": "Describe this bot's responsibilities and boundaries.",
|
"persona": "Describe this bot's responsibilities and boundaries.",
|
||||||
|
"assistantPersona": "Optional speaking personality for the user-facing Assistant; falls back to persona when empty.",
|
||||||
"agent": {
|
"agent": {
|
||||||
"id": "kimi",
|
"id": "kimi",
|
||||||
"command": "/home/USER/.kimi-code/bin/kimi",
|
"command": "/home/USER/.kimi-code/bin/kimi",
|
||||||
@@ -44,11 +45,13 @@
|
|||||||
"acp": {
|
"acp": {
|
||||||
"stateFile": "",
|
"stateFile": "",
|
||||||
"initializeTimeoutMs": 10000,
|
"initializeTimeoutMs": 10000,
|
||||||
"promptTimeoutMs": 7200000,
|
"promptTimeoutMs": 14400000,
|
||||||
"cancelGraceMs": 5000,
|
"cancelGraceMs": 5000,
|
||||||
"idleTimeoutMs": 1800000,
|
"idleTimeoutMs": 1800000,
|
||||||
|
"assistantSessionResetIdleMs": 3600000,
|
||||||
"sweepIntervalMs": 60000,
|
"sweepIntervalMs": 60000,
|
||||||
"maxProcesses": 8
|
"maxProcesses": 8,
|
||||||
|
"maxAssistantSessions": 4
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-3
@@ -21,14 +21,26 @@ 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_ROOT=\"$GORI_AGENT_ROOT\""
|
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"
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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
+81
-11
@@ -4,9 +4,18 @@ 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 { PermissionPolicy } 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: PermissionPolicy;
|
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,45 +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) {
|
||||||
try {
|
try {
|
||||||
await this.connection.agent.request(acp.methods.agent.session.resume, { sessionId, cwd, mcpServers: [] });
|
await request(this.connection.agent.request(acp.methods.agent.session.resume, { sessionId, cwd, mcpServers: [] }), "resume");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!this.capabilities.loadSession) throw error;
|
if (!this.capabilities.loadSession) throw error;
|
||||||
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 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 });
|
||||||
}
|
}
|
||||||
@@ -91,10 +138,33 @@ 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?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function activityCategory(update: SessionNotification["update"]): SafeActivityCategory | undefined {
|
||||||
|
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 {
|
export function decidePermission(request: RequestPermissionRequest, policy: PermissionPolicy): RequestPermissionResponse {
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
import type { AcpConfig } from "../config.js";
|
|
||||||
import { chatKeyFor, type DurableSessionStore, type SessionBinding } from "../core/durable-session-store.js";
|
|
||||||
import type { ResolvedBot } from "../roles/role-registry.js";
|
|
||||||
import type { ConversationRequest, ConversationResponse, ConversationRuntime, RuntimeStats } from "./types.js";
|
|
||||||
import { AcpSessionRestoreError, 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 bot: ResolvedBot,
|
|
||||||
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);
|
|
||||||
let binding = this.store.getBinding(chatKey);
|
|
||||||
if (binding && (binding.botFingerprint !== this.bot.fingerprint || binding.agentId !== this.bot.agent.id || binding.workspace !== this.bot.workspace)) {
|
|
||||||
await this.dropBinding(binding);
|
|
||||||
binding = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
let worker: AcpWorker;
|
|
||||||
try { worker = await this.acquireWorker(binding); }
|
|
||||||
catch (error) {
|
|
||||||
if (!(error instanceof AcpSessionRestoreError) || !binding) throw error;
|
|
||||||
await this.store.deleteBinding(chatKey);
|
|
||||||
binding = undefined;
|
|
||||||
worker = await this.acquireWorker();
|
|
||||||
}
|
|
||||||
this.inFlight.set(chatKey, worker);
|
|
||||||
try {
|
|
||||||
if (!binding) {
|
|
||||||
const now = Date.now();
|
|
||||||
await worker.prompt(this.bot.bootstrap);
|
|
||||||
binding = {
|
|
||||||
chatKey, agentId: this.bot.agent.id, nativeSessionId: worker.nativeSessionId!,
|
|
||||||
workspace: this.bot.workspace, botFingerprint: this.bot.fingerprint, createdAt: now, updatedAt: now
|
|
||||||
};
|
|
||||||
await this.store.setBinding(binding);
|
|
||||||
}
|
|
||||||
const text = await worker.prompt(request.text);
|
|
||||||
await this.store.touchBinding(chatKey);
|
|
||||||
return { text: text || "(ACP agent returned no text)", botId: this.bot.id, agentId: this.bot.agent.id };
|
|
||||||
} catch (error) {
|
|
||||||
if (worker.nativeSessionId) this.workers.delete(workerKey(this.bot.agent.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 binding = await this.store.deleteBinding(chatKey);
|
|
||||||
if (binding) await this.stopWorker(binding.agentId, binding.nativeSessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
status(platform: string, chatId: string): Record<string, string | number | boolean> {
|
|
||||||
const chatKey = chatKeyFor(platform, chatId);
|
|
||||||
const binding = this.store.getBinding(chatKey);
|
|
||||||
return {
|
|
||||||
bot: this.bot.id,
|
|
||||||
agent: this.bot.agent.id,
|
|
||||||
workspace: this.bot.workspace,
|
|
||||||
persisted: Boolean(binding),
|
|
||||||
running: this.inFlight.has(chatKey)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
stats(): RuntimeStats {
|
|
||||||
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(binding?: SessionBinding): Promise<AcpWorker> {
|
|
||||||
if (binding) {
|
|
||||||
const existing = this.workers.get(workerKey(binding.agentId, binding.nativeSessionId));
|
|
||||||
if (existing) return existing;
|
|
||||||
}
|
|
||||||
await this.ensureCapacity();
|
|
||||||
const worker = new AcpWorker(this.bot, this.config, (crashed, error) => {
|
|
||||||
this.crashes++;
|
|
||||||
if (crashed.nativeSessionId) this.workers.delete(workerKey(this.bot.agent.id, crashed.nativeSessionId));
|
|
||||||
console.error(`ACP worker crash: ${error.message}`);
|
|
||||||
});
|
|
||||||
const nativeSessionId = await worker.start(binding?.nativeSessionId);
|
|
||||||
this.workers.set(workerKey(this.bot.agent.id, 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);
|
|
||||||
await this.stopWorker(binding.agentId, binding.nativeSessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async stopWorker(agentId: string, nativeSessionId: string): Promise<void> {
|
|
||||||
const key = workerKey(agentId, nativeSessionId);
|
|
||||||
const worker = this.workers.get(key);
|
|
||||||
if (!worker) return;
|
|
||||||
this.workers.delete(key);
|
|
||||||
await worker.terminate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function workerKey(agentId: string, nativeSessionId: string): string { return `${agentId}:${nativeSessionId}`; }
|
|
||||||
+15
-3
@@ -1,4 +1,6 @@
|
|||||||
import type { PermissionPolicy } 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;
|
||||||
botId: string;
|
botId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
|
mode?: "assistant";
|
||||||
|
images?: OutgoingImageRef[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RuntimeStats {
|
export interface RuntimeStats {
|
||||||
@@ -28,13 +33,20 @@ 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>;
|
||||||
status(platform: string, chatId: string): Record<string, string | number | boolean>;
|
status(platform: string, chatId: string, userId?: 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 {
|
||||||
|
|||||||
+157
-29
@@ -1,32 +1,73 @@
|
|||||||
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 fs from "node:fs";
|
||||||
|
import type { AcpConfig, PermissionPolicy } from "../config.js";
|
||||||
|
import type { WorkerProcessGroup } from "../core/proposal-store.js";
|
||||||
import type { ResolvedBot } from "../roles/role-registry.js";
|
import type { ResolvedBot } from "../roles/role-registry.js";
|
||||||
import { AcpClient } from "./client.js";
|
import { AcpClient, type AcpPromptContent, type SafeActivityCategory } from "./client.js";
|
||||||
|
|
||||||
export class AcpSessionRestoreError extends Error {}
|
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;
|
||||||
private client?: AcpClient;
|
private client?: AcpClient;
|
||||||
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 bot: ResolvedBot,
|
readonly bot: ResolvedBot,
|
||||||
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> {
|
||||||
|
const processToken = crypto.randomUUID();
|
||||||
this.child = spawn(this.bot.agent.command, this.bot.agent.args, {
|
this.child = spawn(this.bot.agent.command, this.bot.agent.args, {
|
||||||
cwd: this.bot.workspace,
|
cwd: this.cwd,
|
||||||
env: { ...process.env, ...this.bot.agent.env },
|
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,14 +76,34 @@ 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.bot.permissions });
|
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.bot.workspace);
|
await this.client.resumeSession(nativeSessionId, this.cwd);
|
||||||
this.nativeSessionId = nativeSessionId;
|
this.nativeSessionId = nativeSessionId;
|
||||||
} else {
|
} else {
|
||||||
this.nativeSessionId = await this.client.newSession(this.bot.workspace);
|
this.nativeSessionId = await this.client.newSession(this.cwd);
|
||||||
}
|
}
|
||||||
return this.nativeSessionId;
|
return this.nativeSessionId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -52,11 +113,15 @@ export class AcpWorker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) => {
|
||||||
@@ -68,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();
|
||||||
}
|
}
|
||||||
@@ -85,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 {
|
||||||
@@ -110,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.bot.agent.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) => {
|
||||||
@@ -127,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -33,6 +34,10 @@ export function loadExampleConfig(): AppConfig {
|
|||||||
return parseConfig(JSON.parse(fs.readFileSync(examplePath, "utf8")) as unknown);
|
return parseConfig(JSON.parse(fs.readFileSync(examplePath, "utf8")) as unknown);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function configDigest(config: AppConfig): string {
|
||||||
|
return crypto.createHash("sha256").update(JSON.stringify(config)).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
export function writeConfigFile(configPath: string, config: AppConfig): void {
|
export function writeConfigFile(configPath: string, config: AppConfig): void {
|
||||||
const parsed = parseConfig(config);
|
const parsed = parseConfig(config);
|
||||||
const directory = path.dirname(configPath);
|
const directory = path.dirname(configPath);
|
||||||
@@ -62,6 +67,7 @@ export function operationalConfigProblems(config: AppConfig): string[] {
|
|||||||
if (isPlaceholder(config.bot.workspace) || config.bot.workspace.includes("/absolute/path/")) problems.push("bot.workspace is still a 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");
|
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");
|
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 {
|
try {
|
||||||
if (!fs.statSync(config.bot.workspace).isDirectory()) problems.push("bot.workspace must be an existing directory");
|
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"); }
|
} catch { problems.push("bot.workspace must be an existing directory"); }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 { BotProfileResolver } 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 { operationalConfigProblems } from "./config-file.js";
|
||||||
import { printUrlHints } from "./net.js";
|
import { printUrlHints } from "./net.js";
|
||||||
|
|
||||||
@@ -25,6 +26,13 @@ export async function runDoctor(config: AppConfig, configPath: string): Promise<
|
|||||||
try { new BotProfileResolver(config); } catch (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));
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const instancesDirectory = path.dirname(path.dirname(configPath));
|
||||||
|
const conflict = findOverlappingWorkspace(config.bot.workspace, config.bot.id, instancesDirectory);
|
||||||
|
if (conflict) problems += reportError(`bot '${conflict}' declares an identical, parent, or child workspace; workspace scopes must be disjoint`);
|
||||||
|
} catch (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.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 === "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) {
|
if (config.bot.permissions.mode === "allowlist" && config.bot.permissions.allowedTools.some((tool) => ["bash", "terminal"].includes(tool.toLowerCase())) && config.bot.permissions.allowedCommandPatterns.length === 0) {
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { assertOperationalConfig, loadConfigFile } from "./config-file.js";
|
import type { AppConfig } from "../config.js";
|
||||||
|
import { findOverlappingWorkspace } from "../core/workspace-scope.js";
|
||||||
import { startServer } from "../server.js";
|
import { startServer } from "../server.js";
|
||||||
|
import { assertOperationalConfig, configDigest, loadConfigFile } from "./config-file.js";
|
||||||
|
|
||||||
interface ShutdownTarget { shutdown(): Promise<void> }
|
interface ShutdownTarget { shutdown(): Promise<void> }
|
||||||
interface SignalEmitter {
|
interface SignalEmitter {
|
||||||
@@ -14,9 +18,28 @@ export async function runInstanceRunner(argv: string[]): Promise<number> {
|
|||||||
if (argv.length !== 1) throw new Error("instance-runner requires exactly one config path");
|
if (argv.length !== 1) throw new Error("instance-runner requires exactly one config path");
|
||||||
const loaded = loadConfigFile(argv[0]);
|
const loaded = loadConfigFile(argv[0]);
|
||||||
assertOperationalConfig(loaded.config, loaded.path);
|
assertOperationalConfig(loaded.config, loaded.path);
|
||||||
|
assertRunnerConfigSnapshot(loaded.config);
|
||||||
|
assertRunnerWorkspaceSafety(loaded.config, loaded.path);
|
||||||
return waitForShutdown(await startServer(loaded.config));
|
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> {
|
export function waitForShutdown(running: ShutdownTarget, signals: SignalEmitter = process): Promise<number> {
|
||||||
return new Promise<number>((resolve) => {
|
return new Promise<number>((resolve) => {
|
||||||
let stopping = false;
|
let stopping = false;
|
||||||
|
|||||||
+63
-6
@@ -6,12 +6,14 @@ import path from "node:path";
|
|||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { defaultStateFile } from "../config.js";
|
import { defaultStateFile } from "../config.js";
|
||||||
import { assertOperationalConfig, loadConfigFile } from "./config-file.js";
|
import { findOverlappingWorkspace } from "../core/workspace-scope.js";
|
||||||
|
import { assertOperationalConfig, configDigest, loadConfigFile } from "./config-file.js";
|
||||||
import { runDoctor } from "./doctor.js";
|
import { runDoctor } from "./doctor.js";
|
||||||
import { localBaseUrl } from "./net.js";
|
import { localBaseUrl } from "./net.js";
|
||||||
import { runSetup } from "./setup.js";
|
import { runSetup } from "./setup.js";
|
||||||
|
|
||||||
const BOT_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62})$/;
|
const BOT_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62})$/;
|
||||||
|
const LIFECYCLE_FLOCK = "/usr/bin/flock";
|
||||||
|
|
||||||
export function agentRoot(): string {
|
export function agentRoot(): string {
|
||||||
return path.resolve(process.env.GORI_AGENT_ROOT || path.join(os.homedir(), ".gori-agent"));
|
return path.resolve(process.env.GORI_AGENT_ROOT || path.join(os.homedir(), ".gori-agent"));
|
||||||
@@ -64,23 +66,30 @@ async function initInstance(botId: string): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function setupInstance(botId: string): Promise<number> {
|
async function setupInstance(botId: string): Promise<number> {
|
||||||
const loaded = loadInstance(botId);
|
loadInstance(botId);
|
||||||
assertConfigMode(loaded.path);
|
|
||||||
const paths = runtimePaths(botId);
|
const paths = runtimePaths(botId);
|
||||||
ensureInstanceDirectories(paths.home, false);
|
ensureInstanceDirectories(paths.home, false);
|
||||||
|
return withInstanceLifecycleLock(paths.home, async () => {
|
||||||
|
const loaded = loadInstance(botId);
|
||||||
|
assertConfigMode(loaded.path);
|
||||||
const current = inspectPid(paths.pidFile, loaded.path);
|
const current = inspectPid(paths.pidFile, loaded.path);
|
||||||
assertSetupPidSafe(botId, current);
|
assertSetupPidSafe(botId, current);
|
||||||
if (current.kind === "stale") removeStalePid(paths.pidFile, current);
|
if (current.kind === "stale") removeStalePid(paths.pidFile, current);
|
||||||
const written = await runSetup(loaded.path, { botId, instancesDirectory: instancesRoot() });
|
const written = await runSetup(loaded.path, { botId, instancesDirectory: instancesRoot() });
|
||||||
if (!written) return 0;
|
if (!written) return 0;
|
||||||
return doctorInstance(botId);
|
return doctorInstance(botId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startInstance(botId: string): Promise<number> {
|
async function startInstance(botId: string): Promise<number> {
|
||||||
const loaded = loadInstance(botId);
|
loadInstance(botId);
|
||||||
assertOperationalConfig(loaded.config, loaded.path);
|
|
||||||
const paths = runtimePaths(botId);
|
const paths = runtimePaths(botId);
|
||||||
ensureInstanceDirectories(paths.home, false);
|
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);
|
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 === "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 === "foreign") throw new Error(`Refusing to start: PID file references unrelated live process ${current.pid}`);
|
||||||
@@ -94,7 +103,11 @@ async function startInstance(botId: string): Promise<number> {
|
|||||||
try {
|
try {
|
||||||
child = spawn(process.execPath, [runnerFile, loaded.path], {
|
child = spawn(process.execPath, [runnerFile, loaded.path], {
|
||||||
cwd: paths.home,
|
cwd: paths.home,
|
||||||
env: { ...process.env, GORI_AGENT_HOME: paths.home },
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GORI_AGENT_HOME: paths.home,
|
||||||
|
GORI_AGENT_CONFIG_DIGEST: configDigest(loaded.config)
|
||||||
|
},
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: ["ignore", logFd, logFd]
|
stdio: ["ignore", logFd, logFd]
|
||||||
});
|
});
|
||||||
@@ -116,6 +129,7 @@ async function startInstance(botId: string): Promise<number> {
|
|||||||
}
|
}
|
||||||
throw new Error(`Instance '${botId}' failed to start; inspect ${paths.logFile}: ${error instanceof Error ? error.message : String(error)}`);
|
throw new Error(`Instance '${botId}' failed to start; inspect ${paths.logFile}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
} finally { fs.closeSync(logFd); }
|
} finally { fs.closeSync(logFd); }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopInstance(botId: string): Promise<number> {
|
async function stopInstance(botId: string): Promise<number> {
|
||||||
@@ -362,6 +376,49 @@ async function waitForHealthyInstance(pid: number, platform: string, botId: stri
|
|||||||
throw new Error(`health check timed out: ${lastError}`);
|
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)); }
|
function delay(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
||||||
|
|
||||||
async function terminateStartedChild(pid: number): Promise<void> {
|
async function terminateStartedChild(pid: number): Promise<void> {
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export async function runSetup(configPath?: string, options: SetupOptions = {}):
|
|||||||
id: botId,
|
id: botId,
|
||||||
workspace,
|
workspace,
|
||||||
persona,
|
persona,
|
||||||
|
assistantPersona: isTemplate ? "" : current.bot.assistantPersona,
|
||||||
agent: configExists ? current.bot.agent : {
|
agent: configExists ? current.bot.agent : {
|
||||||
id: selected!.id,
|
id: selected!.id,
|
||||||
command: selected!.command,
|
command: selected!.command,
|
||||||
|
|||||||
+9
-2
@@ -37,6 +37,7 @@ const botSchema = z.object({
|
|||||||
id: botIdSchema,
|
id: botIdSchema,
|
||||||
workspace: z.string().min(1),
|
workspace: z.string().min(1),
|
||||||
persona: z.string().default(""),
|
persona: z.string().default(""),
|
||||||
|
assistantPersona: z.string().default(""),
|
||||||
agent: agentSchema,
|
agent: agentSchema,
|
||||||
skills: z.array(skillSchema).default([]),
|
skills: z.array(skillSchema).default([]),
|
||||||
permissions: permissionPolicySchema.default({})
|
permissions: permissionPolicySchema.default({})
|
||||||
@@ -83,11 +84,13 @@ const platformSchema = z.discriminatedUnion("type", [feishuSchema, wecomSchema,
|
|||||||
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(7_200_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();
|
}).strict();
|
||||||
|
|
||||||
export const configSchema = z.object({
|
export const configSchema = z.object({
|
||||||
@@ -154,6 +157,10 @@ export function defaultStateFile(config: AppConfig): string {
|
|||||||
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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type CommandKind = "help" | "status" | "cancel" | "new" | "retired-role";
|
export type CommandKind = "help" | "status" | "list" | "confirm" | "finish" | "stop" | "cancel" | "reset";
|
||||||
|
|
||||||
export interface ParsedCommand {
|
export interface ParsedCommand {
|
||||||
kind: CommandKind;
|
kind: CommandKind;
|
||||||
@@ -12,12 +12,12 @@ export class CommandRouter {
|
|||||||
switch (command.toLowerCase()) {
|
switch (command.toLowerCase()) {
|
||||||
case "/help": return { kind: "help" };
|
case "/help": return { kind: "help" };
|
||||||
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 "/roles":
|
|
||||||
case "/role":
|
|
||||||
case "/agents":
|
|
||||||
case "/agent": return { kind: "retired-role" };
|
|
||||||
default: return undefined;
|
default: return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,21 +6,23 @@ export interface StoreIdentity {
|
|||||||
platform: string;
|
platform: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionBinding {
|
export interface AssistantBinding {
|
||||||
chatKey: string;
|
chatKey: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
nativeSessionId: string;
|
nativeSessionId: string;
|
||||||
workspace: string;
|
assistantWorkspace: string;
|
||||||
botFingerprint: 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: 2;
|
version: 3;
|
||||||
botId: string;
|
botId: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
bindings: Record<string, SessionBinding>;
|
bindings: Record<string, AssistantBinding>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DurableSessionStore {
|
export class DurableSessionStore {
|
||||||
@@ -76,12 +78,12 @@ export class DurableSessionStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getBinding(chatKey: string): SessionBinding | undefined {
|
getBinding(chatKey: string): AssistantBinding | undefined {
|
||||||
const value = this.data.bindings[chatKey];
|
const value = this.data.bindings[chatKey];
|
||||||
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[binding.chatKey] = structuredClone(binding);
|
this.data.bindings[binding.chatKey] = structuredClone(binding);
|
||||||
await this.persist();
|
await this.persist();
|
||||||
}
|
}
|
||||||
@@ -90,10 +92,11 @@ export class DurableSessionStore {
|
|||||||
const binding = this.data.bindings[chatKey];
|
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): Promise<SessionBinding | undefined> {
|
async deleteBinding(chatKey: string): Promise<AssistantBinding | undefined> {
|
||||||
const existing = this.data.bindings[chatKey];
|
const existing = this.data.bindings[chatKey];
|
||||||
delete this.data.bindings[chatKey];
|
delete this.data.bindings[chatKey];
|
||||||
if (existing) await this.persist();
|
if (existing) await this.persist();
|
||||||
@@ -133,23 +136,24 @@ 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}`; }
|
||||||
|
|
||||||
function emptyData(identity: StoreIdentity): StoreData {
|
function emptyData(identity: StoreIdentity): StoreData {
|
||||||
return { version: 2, botId: identity.botId, platform: identity.platform, bindings: {} };
|
return { version: 3, botId: identity.botId, platform: identity.platform, bindings: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseStoreData(value: unknown): StoreData {
|
function parseStoreData(value: unknown): StoreData {
|
||||||
if (!isRecord(value) || value.version !== 2 || typeof value.botId !== "string" || typeof value.platform !== "string" || !isRecord(value.bindings)) {
|
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 v2 in a new GORI_AGENT_HOME");
|
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)) {
|
for (const [chatKey, binding] of Object.entries(value.bindings)) {
|
||||||
if (!isRecord(binding)
|
if (!isRecord(binding)
|
||||||
|| binding.chatKey !== chatKey
|
|| binding.chatKey !== chatKey
|
||||||
|| typeof binding.agentId !== "string"
|
|| typeof binding.agentId !== "string"
|
||||||
|| typeof binding.nativeSessionId !== "string"
|
|| typeof binding.nativeSessionId !== "string"
|
||||||
|| typeof binding.workspace !== "string"
|
|| typeof binding.assistantWorkspace !== "string"
|
||||||
|| typeof binding.botFingerprint !== "string"
|
|| typeof binding.botFingerprint !== "string"
|
||||||
|| typeof binding.createdAt !== "number"
|
|| typeof binding.createdAt !== "number"
|
||||||
|| typeof binding.updatedAt !== "number") {
|
|| typeof binding.updatedAt !== "number"
|
||||||
throw new Error(`invalid state v2 binding '${chatKey}'`);
|
|| (binding.lastActiveAt !== undefined && typeof binding.lastActiveAt !== "number")) {
|
||||||
|
throw new Error(`invalid state v3 binding '${chatKey}'`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return value as unknown as StoreData;
|
return value as unknown as StoreData;
|
||||||
|
|||||||
+388
-44
@@ -3,15 +3,122 @@ import type { GatewayPolicy } from "../config.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) {}
|
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);
|
||||||
@@ -20,53 +127,240 @@ export class Gateway {
|
|||||||
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 };
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stats(): ReturnType<ConversationRuntime["stats"]> & { lockedChats: number } { return { ...this.runtime.stats(), lockedChats: this.locks.size }; }
|
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})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
switch (command.kind) {
|
switch (command.kind) {
|
||||||
case "help": return ["Commands:", "/status", "/cancel", "/new", "/help"].join("\n");
|
case "help": return HELP_TEXT;
|
||||||
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.";
|
const chatKey = chatKeyFor(message.platform, message.chatId);
|
||||||
case "cancel": return this.cancelText(message);
|
const owned = (proposal: Proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === message.userId;
|
||||||
case "retired-role": return "Role switching was removed in Config v3; this instance has one fixed Bot and ACP agent.";
|
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 {
|
||||||
@@ -76,16 +370,66 @@ export class Gateway {
|
|||||||
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");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
+126
-15
@@ -3,7 +3,7 @@ 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,9 +12,15 @@ 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(
|
||||||
@@ -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} accepted length=${message.text.length}`);
|
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)}`;
|
||||||
|
|
||||||
|
// 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}`);
|
console.log(`QQ send ${isGroup ? "group" : "user"} message length=${message.text.length}`);
|
||||||
|
|
||||||
const response = await fetch(`https://api.sgroup.qq.com${path}`, {
|
// 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: HTTP ${response.status}`);
|
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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,12 +2,13 @@ import crypto from "node:crypto";
|
|||||||
import type { AppConfig, BotConfig } 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";
|
||||||
|
|
||||||
const BOOTSTRAP_SCHEMA_VERSION = 1;
|
const BOOTSTRAP_SCHEMA_VERSION = 12;
|
||||||
|
|
||||||
export interface ResolvedBot extends BotConfig {
|
export interface ResolvedBot extends BotConfig {
|
||||||
loadedSkills: LoadedSkill[];
|
loadedSkills: LoadedSkill[];
|
||||||
fingerprint: string;
|
fingerprint: string;
|
||||||
bootstrap: string;
|
assistantBootstrap: string;
|
||||||
|
workerBootstrap: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BotProfileResolver {
|
export class BotProfileResolver {
|
||||||
@@ -20,6 +21,7 @@ export class BotProfileResolver {
|
|||||||
id: config.bot.id,
|
id: config.bot.id,
|
||||||
workspace: config.bot.workspace,
|
workspace: config.bot.workspace,
|
||||||
persona: config.bot.persona,
|
persona: config.bot.persona,
|
||||||
|
assistantPersona: config.bot.assistantPersona,
|
||||||
agent: config.bot.agent,
|
agent: config.bot.agent,
|
||||||
permissions: config.bot.permissions,
|
permissions: config.bot.permissions,
|
||||||
skills: loadedSkills.map(({ id, file, hash }) => ({ id, file, hash }))
|
skills: loadedSkills.map(({ id, file, hash }) => ({ id, file, hash }))
|
||||||
@@ -28,18 +30,44 @@ export class BotProfileResolver {
|
|||||||
...config.bot,
|
...config.bot,
|
||||||
loadedSkills,
|
loadedSkills,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
bootstrap: buildBootstrap(config.bot, loadedSkills)
|
assistantBootstrap: buildAssistantBootstrap(config.bot),
|
||||||
|
workerBootstrap: buildWorkerBootstrap(config.bot, loadedSkills)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBootstrap(bot: BotConfig, skills: LoadedSkill[]): string {
|
function buildAssistantBootstrap(bot: BotConfig): string {
|
||||||
|
const persona = bot.assistantPersona || bot.persona;
|
||||||
return [
|
return [
|
||||||
`Initialize this ACP session with gori-agent bootstrap schema v${BOOTSTRAP_SCHEMA_VERSION}. 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.`,
|
||||||
|
`Bot: ${bot.id}`,
|
||||||
|
`Workspace: ${bot.workspace}`,
|
||||||
|
persona ? `Persona:\n${persona}` : "Persona: general coding assistant",
|
||||||
|
"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}`,
|
`Bot: ${bot.id}`,
|
||||||
`Workspace: ${bot.workspace}`,
|
`Workspace: ${bot.workspace}`,
|
||||||
bot.persona ? `Persona:\n${bot.persona}` : "Persona: general coding assistant",
|
bot.persona ? `Persona:\n${bot.persona}` : "Persona: general coding assistant",
|
||||||
`Permission policy enforced by the ACP client: ${JSON.stringify(bot.permissions)}`,
|
`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");
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-11
@@ -1,11 +1,12 @@
|
|||||||
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 { AcpSessionManager } from "./acp/session-manager.js";
|
import { AssistantManager } from "./acp/assistant-manager.js";
|
||||||
import { defaultStateFile, loadConfig, type AppConfig } from "./config.js";
|
import { defaultProposalFile, 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 { 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";
|
||||||
@@ -17,8 +18,9 @@ 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;
|
platformAdapter: PlatformAdapter;
|
||||||
qqGatewayClient?: QqGatewayClient;
|
qqGatewayClient?: QqGatewayClient;
|
||||||
shutdown(): Promise<void>;
|
shutdown(): Promise<void>;
|
||||||
@@ -26,12 +28,26 @@ export interface GatewayRuntime {
|
|||||||
|
|
||||||
export async function createGatewayRuntime(config: AppConfig): Promise<GatewayRuntime> {
|
export async function createGatewayRuntime(config: AppConfig): Promise<GatewayRuntime> {
|
||||||
const platformType = config.gateway.platform.type;
|
const platformType = config.gateway.platform.type;
|
||||||
const store = new DurableSessionStore(defaultStateFile(config), { botId: config.bot.id, platform: platformType });
|
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();
|
||||||
|
try {
|
||||||
|
await proposals.open();
|
||||||
|
} catch (error) {
|
||||||
|
await store.close().catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const bot = new BotProfileResolver(config).bot;
|
const bot = new BotProfileResolver(config).bot;
|
||||||
const sessionManager = new AcpSessionManager(config.runtime.acp, bot, store);
|
assistantManager = new AssistantManager(config.runtime.acp, bot, store, proposals, {
|
||||||
const gateway = new Gateway(config.gateway.policy, sessionManager);
|
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 platformAdapter = createPlatformAdapter(config, gateway);
|
||||||
const qqGatewayClient = config.gateway.platform.type === "qq" && config.gateway.platform.connectionMode === "websocket"
|
const qqGatewayClient = config.gateway.platform.type === "qq" && config.gateway.platform.connectionMode === "websocket"
|
||||||
? new QqGatewayClient(config.gateway.platform, platformAdapter as QqAdapter) : undefined;
|
? new QqGatewayClient(config.gateway.platform, platformAdapter as QqAdapter) : undefined;
|
||||||
@@ -67,16 +83,21 @@ export async function createGatewayRuntime(config: AppConfig): Promise<GatewayRu
|
|||||||
|
|
||||||
let closing: Promise<void> | undefined;
|
let closing: Promise<void> | undefined;
|
||||||
return {
|
return {
|
||||||
app, gateway, sessionManager, store, platformAdapter, qqGatewayClient,
|
app, gateway, assistantManager: manager, store, proposals, platformAdapter, qqGatewayClient,
|
||||||
shutdown: () => closing ||= (async () => {
|
shutdown: () => closing ||= (async () => {
|
||||||
qqGatewayClient?.stop();
|
qqGatewayClient?.stop();
|
||||||
const results = await Promise.allSettled([sessionManager.shutdown(), store.close()]);
|
gateway.shutdown();
|
||||||
const failed = results.find((result): result is PromiseRejectedResult => result.status === "rejected");
|
let shutdownError: unknown;
|
||||||
if (failed) throw failed.reason;
|
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;
|
||||||
})()
|
})()
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await store.close();
|
await assistantManager?.shutdown().catch(() => undefined);
|
||||||
|
await proposals.close().catch(() => undefined);
|
||||||
|
await store.close().catch(() => undefined);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-1
@@ -1,7 +1,12 @@
|
|||||||
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, overrides: Partial<RequestPermissionRequest["toolCall"]> = {}): RequestPermissionRequest => ({
|
const request = (rawInput: unknown, overrides: Partial<RequestPermissionRequest["toolCall"]> = {}): RequestPermissionRequest => ({
|
||||||
sessionId: "session",
|
sessionId: "session",
|
||||||
@@ -30,3 +35,56 @@ test("permission deny and allowlist fail closed", () => {
|
|||||||
assert.deepEqual(decidePermission(request("git status", { name: undefined, 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" });
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,97 +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 { AcpSessionManager } from "../src/acp/session-manager.js";
|
|
||||||
import { parseConfig } from "../src/config.js";
|
|
||||||
import { DurableSessionStore } from "../src/core/durable-session-store.js";
|
|
||||||
import { BotProfileResolver } from "../src/roles/role-registry.js";
|
|
||||||
|
|
||||||
const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
|
|
||||||
|
|
||||||
function config(stateFile: string, logFile: string, persona = "test", agentEnv: Record<string, string> = {}) {
|
|
||||||
return parseConfig({
|
|
||||||
configVersion: 3,
|
|
||||||
bot: {
|
|
||||||
id: "test-bot", workspace: path.resolve("."), persona,
|
|
||||||
agent: { id: "fake", command: process.execPath, args: [fixture], env: { FAKE_ACP_LOG: logFile, ...agentEnv } },
|
|
||||||
skills: [], permissions: { mode: "deny" }
|
|
||||||
},
|
|
||||||
gateway: { platform: { type: "qq", appId: "id", clientSecret: "secret", botNames: ["bot"] } },
|
|
||||||
runtime: { acp: { stateFile, promptTimeoutMs: 2_000, cancelGraceMs: 100, idleTimeoutMs: 100, sweepIntervalMs: 20, maxProcesses: 2 } }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runtime(stateFile: string, logFile: string, persona = "test", agentEnv: Record<string, string> = {}) {
|
|
||||||
const cfg = config(stateFile, logFile, persona, agentEnv);
|
|
||||||
const store = new DurableSessionStore(stateFile, { botId: cfg.bot.id, platform: cfg.gateway.platform.type }); await store.open();
|
|
||||||
const bot = new BotProfileResolver(cfg).bot;
|
|
||||||
return { cfg, store, bot, manager: new AcpSessionManager(cfg.runtime.acp, bot, store) };
|
|
||||||
}
|
|
||||||
|
|
||||||
test("creates, persists, idles, and resumes the same native session", async () => {
|
|
||||||
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.equal(response.botId, "test-bot"); assert.match(response.text, /reply:fake-/);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 180));
|
|
||||||
await first.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "two" });
|
|
||||||
await first.manager.shutdown(); await first.store.close();
|
|
||||||
|
|
||||||
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("resume falls back to load and failed restore creates a new session", async () => {
|
|
||||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-acp-restore-"));
|
|
||||||
const state = path.join(dir, "state.json"); const log = path.join(dir, "fake.log");
|
|
||||||
const resumeFail = path.join(dir, "resume-fail"); const loadFail = path.join(dir, "load-fail");
|
|
||||||
const agentEnv = { FAKE_ACP_RESUME_FAIL_FILE: resumeFail, FAKE_ACP_LOAD_FAIL_FILE: loadFail };
|
|
||||||
const first = await runtime(state, log, "test", agentEnv);
|
|
||||||
const initial = await first.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "one" });
|
|
||||||
const initialSession = initial.text.split(":")[1];
|
|
||||||
await first.manager.shutdown(); await first.store.close();
|
|
||||||
|
|
||||||
await fs.promises.writeFile(resumeFail, "1");
|
|
||||||
const fallback = await runtime(state, log, "test", agentEnv);
|
|
||||||
const loaded = await fallback.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "two" });
|
|
||||||
assert.equal(loaded.text, `reply:${initialSession}:two`);
|
|
||||||
await fallback.manager.shutdown(); await fallback.store.close();
|
|
||||||
|
|
||||||
await fs.promises.writeFile(loadFail, "1");
|
|
||||||
const replacement = await runtime(state, log, "test", agentEnv);
|
|
||||||
const fresh = await replacement.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "three" });
|
|
||||||
assert.notEqual(fresh.text.split(":")[1], initialSession);
|
|
||||||
await replacement.manager.shutdown(); await replacement.store.close();
|
|
||||||
|
|
||||||
const entries = (await fs.promises.readFile(log, "utf8")).trim().split("\n").map(JSON.parse);
|
|
||||||
assert.ok(entries.some((entry) => entry.method === "session/load" && entry.sessionId === initialSession));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("cancel reaches hanging prompt, reset unbinds, and fingerprint changes session", async () => {
|
|
||||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-acp-cancel-")); const state = path.join(dir, "state.json"); const log = path.join(dir, "fake.log");
|
|
||||||
const current = await runtime(state, log);
|
|
||||||
await current.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "ready" });
|
|
||||||
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();
|
|
||||||
|
|
||||||
const original = await runtime(state, log, "one");
|
|
||||||
const first = await original.manager.prompt({ platform: "qq", chatId: "other", userId: "user", text: "first" });
|
|
||||||
const firstSession = first.text.split(":")[1];
|
|
||||||
await original.manager.shutdown(); await original.store.close();
|
|
||||||
const changed = await runtime(state, log, "two");
|
|
||||||
const second = await changed.manager.prompt({ platform: "qq", chatId: "other", userId: "user", text: "second" });
|
|
||||||
assert.notEqual(second.text.split(":")[1], firstSession);
|
|
||||||
await changed.manager.shutdown(); await changed.store.close();
|
|
||||||
});
|
|
||||||
+47
-2
@@ -1,4 +1,6 @@
|
|||||||
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";
|
||||||
@@ -7,10 +9,10 @@ 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: 3,
|
configVersion: 3,
|
||||||
bot: { id: "test-bot", workspace: path.resolve("."), persona: "", agent: { id: "fake", command: process.execPath, args: [fixture] }, permissions: { mode: "deny" } },
|
bot: { id: "test-bot", workspace: path.resolve("."), persona: "", agent: { id: "fake", command: process.execPath, args: [fixture], env }, permissions: { mode: "deny" } },
|
||||||
gateway: { platform: { type: "webhook", secret: "secret" } },
|
gateway: { platform: { type: "webhook", secret: "secret" } },
|
||||||
runtime: { acp: { promptTimeoutMs, cancelGraceMs: 30 } }
|
runtime: { acp: { promptTimeoutMs, cancelGraceMs: 30 } }
|
||||||
});
|
});
|
||||||
@@ -33,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
@@ -28,8 +28,10 @@ test("config writes are atomic and mode 0600", async () => {
|
|||||||
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("operational validation rejects placeholders for every credential family", () => {
|
test("operational validation rejects placeholders, unsafe ACP args, and every credential family", () => {
|
||||||
assert.doesNotThrow(() => assertOperationalConfig(config));
|
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 = [
|
const platforms = [
|
||||||
{ type: "qq", appId: "QQ_APP_ID", clientSecret: "QQ_CLIENT_SECRET", botNames: ["QQ_BOT_NAME"] },
|
{ 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: "feishu", appId: "FEISHU_APP_ID", appSecret: "FEISHU_APP_SECRET" },
|
||||||
|
|||||||
+9
-2
@@ -24,8 +24,15 @@ test("parses Config v3 defaults for one Bot, agent, and platform", () => {
|
|||||||
assert.equal(config.bot.id, "test-bot");
|
assert.equal(config.bot.id, "test-bot");
|
||||||
assert.equal(config.bot.agent.id, "kimi");
|
assert.equal(config.bot.agent.id, "kimi");
|
||||||
assert.equal(config.gateway.platform.type, "webhook");
|
assert.equal(config.gateway.platform.type, "webhook");
|
||||||
assert.equal(config.runtime.acp.promptTimeoutMs, 7_200_000);
|
assert.equal(config.runtime.acp.promptTimeoutMs, 14_400_000);
|
||||||
|
assert.equal(config.runtime.acp.maxAssistantSessions, 4);
|
||||||
assert.equal(config.bot.permissions.mode, "deny");
|
assert.equal(config.bot.permissions.mode, "deny");
|
||||||
|
assert.equal(config.bot.assistantPersona, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses optional bot.assistantPersona", () => {
|
||||||
|
const config = parseConfig(raw({ bot: { ...(raw().bot as object), assistantPersona: "像运维老同事一样讲话" } }));
|
||||||
|
assert.equal(config.bot.assistantPersona, "像运维老同事一样讲话");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("explicitly rejects non-v3 configuration and unknown fields", () => {
|
test("explicitly rejects non-v3 configuration and unknown fields", () => {
|
||||||
@@ -60,7 +67,7 @@ test("config.example.json is a parseable, secret-free documentation template", (
|
|||||||
assert.equal(config.configVersion, 3);
|
assert.equal(config.configVersion, 3);
|
||||||
assert.equal(config.bot.id, "BOT_ID");
|
assert.equal(config.bot.id, "BOT_ID");
|
||||||
assert.equal(config.bot.permissions.mode, "deny");
|
assert.equal(config.bot.permissions.mode, "deny");
|
||||||
assert.equal(config.runtime.acp.promptTimeoutMs, 7_200_000);
|
assert.equal(config.runtime.acp.promptTimeoutMs, 14_400_000);
|
||||||
assert.equal(config.gateway.platform.type, "qq");
|
assert.equal(config.gateway.platform.type, "qq");
|
||||||
assert.match(text, /QQ_CLIENT_SECRET/);
|
assert.match(text, /QQ_CLIENT_SECRET/);
|
||||||
assert.doesNotMatch(text, /configVersion"\s*:\s*[12]/);
|
assert.doesNotMatch(text, /configVersion"\s*:\s*[12]/);
|
||||||
|
|||||||
@@ -3,43 +3,65 @@ 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";
|
||||||
|
|
||||||
const identity = { botId: "test-bot", platform: "qq" };
|
const identity = { botId: "test-bot", platform: "qq" };
|
||||||
|
|
||||||
test("persists state v2 binding across reopen with 0600 atomic file", async () => {
|
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, identity);
|
const first = new DurableSessionStore(file, identity);
|
||||||
await first.open();
|
await first.open();
|
||||||
await first.setBinding({ chatKey: "qq:chat", agentId: "kimi", nativeSessionId: "native-1", workspace: "/tmp", botFingerprint: "fp", createdAt: 1, updatedAt: 1 });
|
await first.setBinding(binding);
|
||||||
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);
|
assert.equal((await fs.promises.stat(file)).mode & 0o777, 0o600);
|
||||||
const persisted = JSON.parse(await fs.promises.readFile(file, "utf8"));
|
const text = await fs.promises.readFile(file, "utf8");
|
||||||
assert.equal(persisted.version, 2); assert.equal(persisted.botId, "test-bot"); assert.equal(persisted.platform, "qq");
|
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, identity);
|
const second = new DurableSessionStore(file, identity);
|
||||||
await second.open();
|
await second.open();
|
||||||
assert.equal(second.getBinding("qq:chat")?.nativeSessionId, "native-1");
|
assert.deepEqual(second.getBinding("qq:chat"), binding);
|
||||||
|
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("rejects old state and bot or platform identity mismatch without rewriting", 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-id-"));
|
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");
|
||||||
const old = '{"version":1,"bindings":{}}\n';
|
const v1 = '{"version":1,"bindings":{}}\n';
|
||||||
await fs.promises.writeFile(file, old);
|
await fs.promises.writeFile(file, v1);
|
||||||
await assert.rejects(new DurableSessionStore(file, identity).open(), /requires state v2/);
|
await assert.rejects(new DurableSessionStore(file, identity).open(), /requires state v3/);
|
||||||
assert.equal(await fs.promises.readFile(file, "utf8"), old);
|
assert.equal(await fs.promises.readFile(file, "utf8"), v1);
|
||||||
|
|
||||||
await fs.promises.writeFile(file, JSON.stringify({ version: 2, botId: "other", platform: "qq", bindings: {} }));
|
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 assert.rejects(new DurableSessionStore(file, identity).open(), /identity mismatch/);
|
||||||
await fs.promises.writeFile(file, JSON.stringify({ version: 2, botId: "test-bot", platform: "feishu", bindings: {} }));
|
await 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 assert.rejects(new DurableSessionStore(file, identity).open(), /identity mismatch/);
|
||||||
|
|
||||||
await fs.promises.writeFile(file, JSON.stringify({ version: 2, botId: "test-bot", platform: "qq", bindings: { "qq:chat": { chatKey: "qq:other" } } }));
|
await 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 v2 binding/);
|
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("preserves corrupt state and refuses a second writer lock", async () => {
|
test("preserves corrupt state and refuses a second writer lock", async () => {
|
||||||
@@ -60,3 +82,8 @@ test("preserves corrupt state and refuses a second writer lock", async () => {
|
|||||||
await recovered.close();
|
await recovered.close();
|
||||||
assert.equal(fs.existsSync(`${file}.lock`), false);
|
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");
|
||||||
|
});
|
||||||
|
|||||||
Vendored
+159
-16
@@ -1,24 +1,36 @@
|
|||||||
#!/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 }) => {
|
.onRequest(acp.methods.agent.session.load, ({ params }) => {
|
||||||
@@ -35,21 +47,80 @@ const app = acp.agent({ name: "fake-acp-agent" })
|
|||||||
.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);
|
||||||
@@ -58,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 }) => {
|
||||||
|
|||||||
+718
-27
@@ -1,41 +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 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 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 {
|
||||||
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", botId: "test-bot", agentId: "kimi" }; }
|
cancelled = 0;
|
||||||
async cancel() { this.cancelled++; this.release?.(); return true; }
|
confirmed = 0;
|
||||||
async reset() { this.resets++; }
|
finished = 0;
|
||||||
status() { return { bot: "test-bot", agent: "kimi", workspace: "/tmp", running: Boolean(this.release) }; }
|
stopped = 0;
|
||||||
|
listed = 0;
|
||||||
|
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 policy = { allowedUsers: [] as string[], allowedChats: [] as string[], requireMentionInGroup: false };
|
const policy = { allowedUsers: [] as string[], allowedChats: [] as string[], requireMentionInGroup: false };
|
||||||
const adapter: PlatformAdapter = { name: "test", async handleWebhook() { return {}; }, async sendMessage() {} };
|
const message = (text: string, messageId = text, chatId = "chat"): IncomingMessage => ({
|
||||||
const message = (text: string): IncomingMessage => ({ platform: "qq", chatId: "chat", userId: "user", text });
|
platform: "qq", chatId, userId: "user", text, messageId
|
||||||
|
|
||||||
test("cancel bypasses the chat lock and new resets", async () => {
|
|
||||||
const runtime = new FakeRuntime(); const gateway = new Gateway(policy, runtime);
|
|
||||||
const turn = gateway.receive(message("work"), adapter, { synchronous: true });
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
||||||
const cancelled = await gateway.receive(message("/cancel"), adapter, { synchronous: true });
|
|
||||||
assert.equal(cancelled.reply, "Cancellation requested.");
|
|
||||||
await turn;
|
|
||||||
const reset = await gateway.receive(message("/new"), adapter, { synchronous: true });
|
|
||||||
assert.match(reset.reply || "", /new native ACP session/);
|
|
||||||
assert.equal(runtime.resets, 1);
|
|
||||||
});
|
});
|
||||||
|
const groupMessage = (text: string, messageId: string): IncomingMessage => ({
|
||||||
test("fixed Bot status and retired role commands never reach ACP", async () => {
|
platform: "qq", chatId: "group:g1", userId: "user", text, messageId, isGroup: true
|
||||||
const runtime = new FakeRuntime(); const gateway = new Gateway(policy, runtime);
|
});
|
||||||
for (const command of ["/roles", "/role ops", "/agents", "/agent ops"]) {
|
const flush = async () => { await Promise.resolve(); await Promise.resolve(); await new Promise<void>((resolve) => setImmediate(resolve)); };
|
||||||
assert.match((await gateway.receive(message(command), adapter, { synchronous: true })).reply || "", /removed in Config v3/);
|
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.match((await gateway.receive(message("/status"), adapter, { synchronous: true })).reply || "", /bot=test-bot/);
|
assert.equal(condition(), true);
|
||||||
assert.equal(runtime.prompts, 0);
|
};
|
||||||
|
|
||||||
|
function recordingAdapter(sent: OutgoingMessage[] = [], supportsImages = false): PlatformAdapter {
|
||||||
|
return { name: "test", supportsImages, async handleWebhook() { return {}; }, async sendMessage(outgoing) { sent.push(outgoing); } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulates QQ passive-reply dedup: a repeated msg_id + msg_seq pair is rejected.
|
||||||
|
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;
|
||||||
|
assert.deepEqual(sent.map((outgoing) => outgoing.text), ["done"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ordinary messages reach the runtime immediately without a Gateway queue", async () => {
|
||||||
|
const { runtime, gateway } = createGateway();
|
||||||
|
const sent: OutgoingMessage[] = [];
|
||||||
|
const adapter = recordingAdapter(sent);
|
||||||
|
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/);
|
||||||
});
|
});
|
||||||
|
|||||||
+25
-2
@@ -4,9 +4,9 @@ import { EventEmitter } from "node:events";
|
|||||||
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 { writeConfigFile } from "../src/cli/config-file.js";
|
import { configDigest, writeConfigFile } from "../src/cli/config-file.js";
|
||||||
import { assertSetupPidSafe, instanceDirectory, instanceRunnerPath, matchesInstanceRunner, runInstanceCommand } from "../src/cli/instance.js";
|
import { assertSetupPidSafe, instanceDirectory, instanceRunnerPath, matchesInstanceRunner, runInstanceCommand } from "../src/cli/instance.js";
|
||||||
import { runInstanceRunner, waitForShutdown } from "../src/cli/instance-runner.js";
|
import { assertRunnerConfigSnapshot, assertRunnerWorkspaceSafety, runInstanceRunner, waitForShutdown } from "../src/cli/instance-runner.js";
|
||||||
import { parseConfig } from "../src/config.js";
|
import { parseConfig } from "../src/config.js";
|
||||||
|
|
||||||
test("instance paths reject traversal and config identity mismatch", async () => {
|
test("instance paths reject traversal and config identity mismatch", async () => {
|
||||||
@@ -69,6 +69,29 @@ test("instance runner rejects unsafe argument and config paths before starting a
|
|||||||
await assert.rejects(runInstanceRunner([path.join(os.tmpdir(), `missing-runner-${Date.now()}.json`)]), /Runtime config does not exist/);
|
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 () => {
|
test("instance runner gracefully shuts down once on SIGTERM", async () => {
|
||||||
const signals = new EventEmitter();
|
const signals = new EventEmitter();
|
||||||
let shutdowns = 0;
|
let shutdowns = 0;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
+252
-7
@@ -8,28 +8,273 @@ const config: QqConfig = {
|
|||||||
botNames: ["Bot"], intents: 33_554_432, shard: [0, 1]
|
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, 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, { 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; }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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")));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user