/** * AgentMail 网关的 HTTP 客户端。 * * 与 pi / dsh / opencode 三个桥的同名模块**同一套请求头与端点** * (`Authorization: Bearer ` + `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 ` 或 **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; }