Files
MailUI4Agents/plugins/pi-mail-bridge/src/gateway.mjs
JianFeeeee 429149e118 chore(format): 撤销误入提交的整体重排,并关闭本仓库的格式化器
# 发生了什么

pi-lens 内置「安全格式化」:它会自动安装 biome 并对**编辑过的文件**跑
`biome format --write`。本机原先没有任何 biome 配置,于是 biome 用它自己的
默认值 —— tab 缩进 + 双引号 —— 把文件整体重写。

我在 19a3161 那次提交里用了 `git add -A`,把这批与功能无关的重排一起扫了进去:
约 7000 行改动散落在 20 个文件上,使那次提交无法审查,还掩盖了
server/internal/handler/permission.go 的一处删行(实为文件末尾空行,无代码丢失)。

# 为什么是「关掉」而不是「配置成我们的风格」

试过把缩进/引号/lineWidth 全部对齐本仓库习惯(biome.json + space/2/single/
lineWidth 120):`biome format --write` 仍然改动 17 个文件。原因是本仓库从未按
biome 的规则排版过 —— 注释按语义换行、数组与调用按可读性手工折行,
这些无法由格式化器还原。也就是说只要格式化器开着,每次编辑都会产生与内容无关的
大面积 diff,把真正的改动埋掉。

因此 biome.jsonc 里 formatter 与 linter 都关闭:本仓库的静态检查由
tsc / go vet / tree-sitter / ast-grep 与各自测试套件承担,不引入会改动无关行的
自动修复。

(pi-lens 这一版把 format 服务的 enabled 硬编码为 true,没有配置开关,
所以只能在仓库侧用 biome 配置让它不动文件;已验证 `biome format --write`
对这些文件零改动。)

# 本提交内容

把 19a3161 里除「有意改动」外的 20 个文件还原到重排前的样子。
19a3161 中真正有意的改动是 deploy/install.sh 的扩展注册与
plugins/pi-mail-bridge/extension/index.ts 新文件,两者原样保留。

验证:Go 全量、三桥插件(320/362/409)、前端 196 全绿;
`biome format --write` 对还原后的文件零改动。
2026-09-11 12:03:51 +08:00

213 lines
7.6 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 Gateway 客户端 —— HTTP + SSE。
*
* SSE 部分委托给共用模块 lib/sse-client.js(三桥逐字节同源),
* 本文件只管认证头、密钥解析与坐标变更。
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { randomBytes } from 'node:crypto';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { createSSEClient } from '../lib/sse-client.js';
const CONFIG_DIR = process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), '.agentmail');
export const KEY_FILE = join(CONFIG_DIR, 'agent.key');
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
/**
* 把管理员给的密钥落盘(0600)。
*
* connect_to_server 工具靠它:模型拿到一把新密钥后必须落盘,
* 否则重启后又回到无法连接的状态 —— 而那正是这个工具要解决的问题。
*/
export function saveLocalKey(token) {
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
writeFileSync(
KEY_FILE,
JSON.stringify({ key_token: token, created_at: new Date().toISOString() }, null, 2),
{ mode: 0o600 },
);
}
/** 读取本地密钥文件;不存在或损坏时返回 null。 */
export function readLocalKey() {
try {
if (!existsSync(KEY_FILE)) return null;
const raw = JSON.parse(readFileSync(KEY_FILE, 'utf8'));
return typeof raw?.key_token === 'string' && raw.key_token ? raw.key_token : null;
} catch {
return null;
}
}
/**
* 首次安装时本地生成密钥并落盘(0600),**并把全文打印到日志**(B-1.1)。
*
* 打印是必须的:密钥要管理员在后台登记之后才能接入,不打印就没人知道登记什么。
* 走 console.error 而不是任何结构化日志 —— 它一定进 journalctl(契约 9.8)。
*/
export function generateLocalKey(log = console.error) {
const token = randomBytes(32).toString('hex');
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
writeFileSync(
KEY_FILE,
JSON.stringify({ key_token: token, created_at: new Date().toISOString() }, null, 2),
{ mode: 0o600 },
);
// 调用方传进来的 log 已经带 [pi-mail-bridge] 前缀,这里不再自己加
log(`已在 ${KEY_FILE} 生成本地密钥。`);
log(`该密钥需管理员在 AgentMail 后台登记后才能接入:`);
log(` ${token}`);
return token;
}
/** 把 gateway 地址与身份记到 config.json,便于换机时人工核对。 */
export function saveConfig(extra) {
try {
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
let cur = {};
if (existsSync(CONFIG_FILE)) {
try { cur = JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch { /* 损坏就重写 */ }
}
writeFileSync(CONFIG_FILE, JSON.stringify({ ...cur, ...extra }, null, 2), { mode: 0o600 });
} catch (e) {
console.error('[pi-mail-bridge] 写 config.json 失败:', e?.message || e);
}
}
export class GatewayClient {
/**
* @param {{url: string, agentName: string, agentKey: string, agentSecret: string}} opts
*/
constructor({ url, agentName, agentKey, agentSecret }) {
this.baseURL = String(url || 'http://127.0.0.1:8180').replace(/\/+$/, '');
this.agentName = agentName;
this.agentKey = agentKey || '';
this.agentSecret = agentSecret || '';
this.sseClient = null;
}
/** 认证头:有密钥走 Bearer,否则退回 name/secret。 */
authHeaders() {
if (this.agentKey) {
return { Authorization: `Bearer ${this.agentKey}`, 'X-Agent-Name': this.agentName };
}
return { 'X-Agent-Name': this.agentName, 'X-Agent-Secret': this.agentSecret };
}
async get(path) {
const res = await fetch(`${this.baseURL}/api/v1${path}`, { headers: this.authHeaders() });
if (!res.ok) throw new Error(`GET ${path} 失败: HTTP ${res.status}`);
return res.json();
}
async post(path, body) {
const res = await fetch(`${this.baseURL}/api/v1${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(data?.error || `POST ${path} 失败: HTTP ${res.status}`);
err.status = res.status;
// 响应体也带上:服务端对 409 会给 detail/suggestion,
// 那些文字要原文转给模型(它据此决定换什么做法)。
err.body = data;
throw err;
}
return data;
}
/** 注册。workspaces 传 [](B-1.2)—— 工作目录由每封邮件的 to_workspace 决定。 */
async register() {
return this.post('/agent/register', {
name: this.agentName,
secret: this.agentSecret || '',
workspaces: [],
platform: 'pi',
});
}
/**
* 上传附件。
*
* 必须走 multipart 的 `file` 字段:服务端是 `r.FormFile("file")`,
* 且**不认 `X-Filename` 头**(grep 过 handler/attachments.go,没有这个分支)。
* 直接 POST 二进制体会得到 400「缺少 file 字段」。
*
* 不手动设 Content-Type:让 undici 按 FormData 自己生成 boundary。
*/
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 res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error || `上传失败: HTTP ${res.status}`);
return data.attachment;
}
async downloadFile(attachmentID) {
const res = await fetch(`${this.baseURL}/api/v1/attachments/${attachmentID}`, {
headers: this.authHeaders(),
});
if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`);
return Buffer.from(await res.arrayBuffer());
}
/**
* 建立 SSE 长连并自动重连。
*
* 实现委托给共用模块 `lib/sse-client.js`(三桥逐字节同源,由
* deploy/check-shared-libs.sh 校验)—— 那里把「跨 TCP 分片保帧状态」与
* 「Last-Event-ID 断点续传」两件事写对了一次,不必每个平台各抄一遍。
*
* 断线重连带 `Last-Event-ID`(D-7.2):服务端有 per-agent 环形缓冲,
* 能把断连期间的事件回放出来 —— 否则那段时间的邮件只能等下次重启补拉。
* 首次连接**不带**(N-11):那会让服务端把缓冲区里的旧事件全回放一遍。
*/
startSSE(onEvent, log = console.error) {
this.sseClient?.stop?.();
this.sseClient = createSSEClient({
authHeaders: () => this.authHeaders(),
baseURL: this.baseURL,
path: '/api/v1/events/stream',
onEvent,
log,
});
}
/**
* 换 Gateway 地址或换密钥。
*
* 守护进程不能靠重启来应用新配置 —— connect_to_server 是模型在**运行中**
* 调的,它期望调完就能收信。所以这里除了改字段还要重置断点:
* `lastEventID` 是**旧** Gateway 环形缓冲里的序号,拿去问新 Gateway 会
* 命中一段完全无关的历史(或直接被拒),得到的事件属于别人的会话。
*
* @returns {boolean} 是否真的变了(没变就不必重连 SSE,省一次断流)
*/
reconfigure({ url, agentKey }) {
const nextURL = url ? String(url).replace(/\/+$/, '') : this.baseURL;
const nextKey = agentKey || this.agentKey;
const changed = nextURL !== this.baseURL || nextKey !== this.agentKey;
if (!changed) return false;
if (nextURL !== this.baseURL) this.sseClient?.reset?.();
this.baseURL = nextURL;
this.agentKey = nextKey;
return true;
}
stopSSE() {
this.sseClient?.stop?.();
this.sseClient = null;
}
}