真模型端到端(场景 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 项。
151 lines
5.8 KiB
JavaScript
151 lines
5.8 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|