Files
MailUI4Agents/plugins/zcode-mail-bridge/lib/gateway.mjs
JianFeeeee 42816b4d1e fix(zcode): 真模型跑通后发现的三处缺陷(register / 工具活动日志 / SSE 关停)
真模型端到端(场景 A 通过:6893 事件、175 秒、530 字回信)把三处只有真跑才
暴露的问题照了出来:

1. **register 调不通**:驱动按 pi 的客户端 API 写了 `client.register()`,
   而本插件的 GatewayClient 没有这个方法 —— 靠此前手工注册过才没暴露。
   补上后才发现第二个坑:`/agent/register` 的认证与其它接口**不同**,
   它只认 `Authorization: Bearer` 或 **body 里的 `secret`**,不认 `X-Agent-Secret`
   头(其它接口认)。实测报错:
     HTTP 400 需要 Authorization: Bearer <密钥> 或 body 里的 secret
   所以没密钥时把 secret 放进 body。

2. **一轮 6893 条事件,日志里什么也看不见**:邮件驱动的会话没有界面,
   「模型正在干什么」只能来自日志,否则一个五分钟的回合与一个卡死的回合
   在外部完全一样。新增 `describeRunEvent`,只记工具调用与权限事件
   (全记等于没有日志),并由 runTurn 通过 onEvent 逐个交出来。

3. **关停没真断 SSE**:驱动调的是 `client.stopSSE?.()`,而客户端没有这个方法
   (`?.` 让它静默变成空操作)。改成持有 createSSEClient 的句柄并在关停时 stop。

验证:单元 325/325、授权桥 e2e 5/5、驱动 e2e(桩)7/7、快照握手 12 项。
2026-09-12 16:16:37 +08:00

151 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* AgentMail 网关的 HTTP 客户端。
*
* 与 pi / dsh / opencode 三个桥的同名模块**同一套请求头与端点**
* `Authorization: Bearer <key>` + `X-Agent-Name`,路径前缀 `/api/v1`)。
* 刻意不共用文件:那三个桥的客户端与各自平台的会话生命周期耦合,
* 而这个只服务 MCP 的请求-响应模型;共用的部分(地址解析、收件箱渲染、
* 附件 id 归一)已经抽在 `lib/` 里逐字节同源。
*/
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { dirname, basename } from 'node:path';
/** 与网关一致的默认值;无头部署时由 ZCode 的 userConfig / 环境变量覆盖。 */
const DEFAULT_BASE = 'http://127.0.0.1:8180';
export class GatewayError extends Error {
constructor(status, body, path) {
// 把状态码与响应体一起带上:只报「请求失败」会让模型无从改正
// (是密钥不对?会话别名被占?预算用尽?三种要完全不同的应对)。
super(`HTTP ${status} ${path}${body ? `${body}` : ''}`);
this.status = status;
this.body = body;
this.path = path;
}
}
export class GatewayClient {
constructor(env = process.env) {
this.baseURL = String(env.AGENTMAIL_GATEWAY_URL || DEFAULT_BASE).replace(/\/+$/, '');
this.agentKey = String(env.AGENTMAIL_AGENT_KEY || '').trim();
this.agentSecret = String(env.AGENTMAIL_AGENT_SECRET || '').trim();
this.agentName = String(env.AGENTMAIL_AGENT_NAME || '').trim();
}
/** 配置是否足以发请求 —— 缺密钥时要在第一次调用就明确报错,而不是收到 401 再猜。 */
checkConfig() {
const missing = [];
if (!this.agentKey && !this.agentSecret) missing.push('AGENTMAIL_AGENT_KEY');
if (!this.agentName) missing.push('AGENTMAIL_AGENT_NAME');
return missing;
}
authHeaders() {
const headers = { 'X-Agent-Name': this.agentName };
// 密钥优先;没有密钥时退回 secret与 pi/opencode/dsh 三桥同款兜底,
// 服务端两条路都认)。两者都没有时上面 checkConfig 已经拦住了。
if (this.agentKey) headers.Authorization = `Bearer ${this.agentKey}`;
else if (this.agentSecret) headers['X-Agent-Secret'] = this.agentSecret;
return headers;
}
/**
* 向网关登记自己(`POST /agent/register`)。
*
* 驱动启动时调一次。**不能省**:没登记过的新部署只会在心跳与 SSE 上
* 反复受拒,而日志里只有看不见的 4xx —— 而驱动的日志是唯一能被看到的地方。
*
* 注意这个端点的认证方式与其它接口**不同**:它只认
* `Authorization: Bearer <key>` 或 **body 里的 `secret`**
* 不认 `X-Agent-Secret` 头(其它接口认)。实测踩过:
* HTTP 400 需要 Authorization: Bearer <密钥> 或 body 里的 secret
* 所以没密钥时把 secret 放进 body。
*/
async register(extra = {}) {
if (!this.agentName) throw new Error('缺少 AGENTMAIL_AGENT_NAME');
if (!this.agentKey && !this.agentSecret) {
throw new Error('缺少 AGENTMAIL_AGENT_KEY 或 AGENTMAIL_AGENT_SECRET');
}
const body = { name: this.agentName, platform: 'zcode', ...extra };
if (!this.agentKey) body.secret = this.agentSecret;
return this.post('/agent/register', body);
}
async get(path) {
const res = await fetch(`${this.baseURL}/api/v1${path}`, { headers: this.authHeaders() });
return this.#parse(res, path);
}
async post(path, body) {
const res = await fetch(`${this.baseURL}/api/v1${path}`, {
method: 'POST',
headers: { ...this.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(body ?? {})
});
return this.#parse(res, path);
}
async #parse(res, path) {
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!res.ok) {
// 服务端的错误信息是给人看的(中文、可操作),优先透传给模型
const message = (data && (data.error || data.message)) || text.slice(0, 300);
throw new GatewayError(res.status, message, path);
}
return data;
}
/**
* 上传附件。字段名必须是 `file`(服务端 `FormFile("file")`
* 返回的是 `{attachment:{...}}` 这种**嵌套**形状 —— 按顶层解会得到空 id
* 而那是静默的homeagent 踩过HTTP 200、附件数为 0
*/
async uploadFile(buf, filename) {
const form = new FormData();
form.append('file', new Blob([buf]), filename);
const res = await fetch(`${this.baseURL}/api/v1/attachments`, {
method: 'POST',
headers: this.authHeaders(),
body: form
});
const data = await this.#parse(res, '/attachments');
const attachment = data?.attachment;
if (!attachment?.attachment_id) {
throw new Error('上传响应里没有 attachment_id服务端响应结构可能已变更');
}
return attachment;
}
async downloadFile(attachmentID) {
const res = await fetch(`${this.baseURL}/api/v1/attachments/${attachmentID}`, {
headers: this.authHeaders()
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new GatewayError(res.status, body.slice(0, 200), `/attachments/${attachmentID}`);
}
return Buffer.from(await res.arrayBuffer());
}
}
/** 读本地文件并上传,返回附件的展示用信息。 */
export async function uploadLocalFile(client, filePath) {
const data = await readFile(filePath);
return client.uploadFile(data, basename(filePath));
}
/** 下载附件并落盘,必要时建父目录。 */
export async function downloadToFile(client, attachmentID, savePath) {
const buf = await client.downloadFile(attachmentID);
await mkdir(dirname(savePath), { recursive: true });
await writeFile(savePath, buf);
return buf.length;
}