fix: relay 死循环防护 + DSH 工作区注册修复 + homeagent 工具集补齐 + 三插件 connect_to_server
## relay 死循环防护(两道防线) ### 主防线:免配额只给发往人类的 relay(handler/mail.go) 原设计:relay 走免配额通道(harness 搬运不该算模型自主发信)。 问题:收件方是另一个同样会自动转发的 Agent 时,整个回路里没有任何 一处在计数——生产上跑出过 41 封(会话 f3d824ce),间隔从 15 分钟 缩到 5 秒,且用了 37 封才烧掉 4/20 预算。 改为:repo.IsHumanUser(to.Name) 判定。Agent→Agent 的 relay 照样扣预算。 顺带修次序问题:原来是「先占幂等键再扣预算」,预算耗尽时幂等键 已被占用,加了额度也无法重发。现在预算失败会 ReleaseRelay 还回去。 ### 兜底:hop_limit 列接通(repo/relayhops.go) schema 里早有 hop_limit INT DEFAULT 5,从未有代码读它。 CountTrailingRelayHops 从最新邮件往前扫,遇到第一封非 relay 邮件即停(中间有一封自主发信或人类插话就归零)。 5 测试:空会话 / 只数 relay / 自主发信打断归零 / 达到上限 / 按会话独立 ## DSH 工作区注册修复 问题:上一轮加的 workspaceRegistry.create(cwd) 用了兜底值 cwd(来自 resolveWorkspaceCwd,可能是 ~/.dsh/mail-sessions/mail-<uuid>), 而不是会话 header 里的真实 cwd。两者不一致时 attachSession 拒绝, 且 create 已先执行,每封邮件都往注册表里塞一条空的垃圾 workspace。 修复:读 handle.agent.session.header.cwd —— create 路径下是 meta.cwd, resume 路径下是持久化 header 里那个。 ## homeagent 插件:11 工具齐平 opencode tools.go 新增:read_mail / forward_mail / suggest_address / list_contacts / session_participants / read_thread / connect_to_server + handleConnectToServer(注册到 Gateway 前先用候选坐标试注册, 成功才写回 p.gwURL/p.key,失败不破坏原配置) 关键修:Plugin.name(插件名,homed 注册用)与 Plugin.agentName (AgentMail 身份,Gateway 密钥绑定用)是两个命名空间。 它们混淆会导致 403:「该密钥已绑定到 Agent 'homeagent',不能用于 注册 'homeagent-mail-bridge'」。现已分开,并在 systemd drop-in 里显式设 AGENTMAIL_AGENT_NAME=homeagent。 ## 三插件补齐 connect_to_server 之前只有 opencode 有。后果:Gateway 换地址或密钥需要重新登记时, opencode 里的模型能自己修好,其他平台只能干等环境变量被人改。 DSH 版:从 GatewayClient 内部调 register(),成功后写回 client.baseURL 与 client.agentKey 当场生效。 pi 版:新导出 KEY_FILE / saveLocalKey(从 gateway.mjs),connect 工具直接用。 # 测试 relayhops_test.go 5 例 opencode 172 / dsh 188 / pi 214 全绿 check-shared-libs.sh 三方同源(rename-proposal 已纳入校验)
This commit is contained in:
18
plugins/dsh-mail-bridge/lib/rename-proposal.d.ts
vendored
Normal file
18
plugins/dsh-mail-bridge/lib/rename-proposal.d.ts
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
export declare function isProposableAlias(alias: string | null | undefined): boolean;
|
||||
|
||||
export interface ProposalResult {
|
||||
body: string;
|
||||
proposed: boolean;
|
||||
}
|
||||
|
||||
export declare function appendRenameProposal(
|
||||
body: string,
|
||||
alias?: string,
|
||||
reason?: string,
|
||||
): ProposalResult;
|
||||
|
||||
export declare function renameProposalNote(
|
||||
serverAlias?: string,
|
||||
requestedAlias?: string,
|
||||
proposed?: boolean,
|
||||
): string;
|
||||
109
plugins/dsh-mail-bridge/lib/rename-proposal.js
Normal file
109
plugins/dsh-mail-bridge/lib/rename-proposal.js
Normal file
@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 会话改名提议 —— 所有平台插件共用。
|
||||
*
|
||||
* # 这是什么
|
||||
*
|
||||
* 模型干完活后可能觉得当前别名不贴切:会话建立时叫 `witty-planet`(平台随机 slug)
|
||||
* 或 `排查登录问题`(人写的邮件主题),摸清问题后它知道这其实是
|
||||
* `fix-session-cookie-leak`。改名提议就是让它把这个判断说出来。
|
||||
*
|
||||
* # 为什么是「提议」而不是直接改
|
||||
*
|
||||
* 别名是**人**的寻址入口 —— `name@path.<别名>` 里那一段。Agent 干到一半自己改掉,
|
||||
* 人上一秒记住的地址下一秒就 404(`session` 位三态语义要求指向不存在的会话直接报
|
||||
* 「无法送达」,不会静默新建)。所以提议入库、由人在界面上点「接受」才真正生效。
|
||||
*
|
||||
* 这与平台命名自动同步(`POST /sessions/{id}/sync`)互补,两者不冲突:
|
||||
*
|
||||
* | | 谁发起 | 何时 | 是否打扰人 |
|
||||
* |---|---|---|---|
|
||||
* | 自动同步 | 平台的命名机制 | 每轮结束 | 不,后台静默生效 |
|
||||
* | 改名提议 | 模型的主动判断 | 它认为有必要时 | 是,界面上出提示条 |
|
||||
*
|
||||
* # 为什么载体是 HTML 注释
|
||||
*
|
||||
* `/mail/send` 没有 `propose_alias` 字段 —— 提议**搭在正文里**发出去,
|
||||
* 服务端用正则摘出来再把标记从入库正文中剥掉。选 HTML 注释的三个理由:
|
||||
*
|
||||
* - react-markdown 默认不解析 raw HTML,万一服务端没剥掉,它在页面上也只是
|
||||
* 一行不显眼的转义文本,不会破版
|
||||
* - 纯文本邮件客户端里是一行不碍事的注释,不像自造标记那样显眼
|
||||
* - 不与 Markdown 语法冲突,格式化工具不会改写它
|
||||
*
|
||||
* # 为什么必须共用
|
||||
*
|
||||
* 标记格式是**服务端正则的镜像**(`gateway/internal/handler/rename_proposal.go`)。
|
||||
* 各平台各写一遍拼接,某一处少个空格或把双引号写成单引号,服务端匹配不上 ——
|
||||
* 而失败是静默的:邮件照常发出,提议凭空消失,模型以为自己提过了。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 服务端能识别的别名字符集。
|
||||
*
|
||||
* 与 `validateSessionAlias` 一致:`. 空白 / @` 会与三维地址解析冲突,
|
||||
* `new` 是寻址保留字。这里**不做规范化**(不把非法字符替换成 `-`)——
|
||||
* 规范化是服务端 `normalizeAlias` 的职责,插件擅自改写会让模型看到的
|
||||
* 「我提议的名字」与实际入库的不一致。
|
||||
*
|
||||
* @param {string} alias
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isProposableAlias(alias) {
|
||||
const a = String(alias ?? '').trim();
|
||||
if (!a) return false;
|
||||
if (a === 'new') return false;
|
||||
// 双引号是标记本身的定界符,含它会截断标记
|
||||
if (/[.\s/@"]/.test(a)) return false;
|
||||
// 服务端 VARCHAR(128),按字节算
|
||||
if (Buffer.byteLength(a, 'utf8') > 128) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把改名提议标记追加到正文末尾。
|
||||
*
|
||||
* 格式必须与服务端正则逐字符对应:
|
||||
* `<!-- agentmail:rename-session alias="x" reason="y" -->`
|
||||
* reason 可选,为空时**整个属性都不写**(写成 `reason=""` 服务端会存一个空理由,
|
||||
* 界面上的提示条就少了那句解释)。
|
||||
*
|
||||
* 别名不合法时**原样返回正文**,不追加标记:与其发一个服务端匹配得上却
|
||||
* 被 `validateSessionAlias` 拒掉的标记,不如当它没提 —— 调用方据此告诉模型。
|
||||
*
|
||||
* @param {string} body 原始正文
|
||||
* @param {string} [alias] 提议的别名
|
||||
* @param {string} [reason] 提议理由,一句话
|
||||
* @returns {{body: string, proposed: boolean}} proposed=false 表示别名不合法,未追加
|
||||
*/
|
||||
export function appendRenameProposal(body, alias, reason) {
|
||||
const text = String(body ?? '');
|
||||
if (!isProposableAlias(alias)) return { body: text, proposed: false };
|
||||
|
||||
const a = String(alias).trim();
|
||||
// 理由里的双引号会截断标记,去掉而不是转义:HTML 注释里没有转义机制
|
||||
const r = String(reason ?? '').replace(/"/g, '').trim();
|
||||
const reasonAttr = r ? ` reason="${r}"` : '';
|
||||
|
||||
return {
|
||||
body: `${text}\n\n<!-- agentmail:rename-session alias="${a}"${reasonAttr} -->`,
|
||||
proposed: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 提议提交后回给模型的那句话。
|
||||
*
|
||||
* 必须说明「等人确认」。不说的话模型会以为改名已经生效,接着在后续邮件里
|
||||
* 用新别名当地址发信 —— 而那个别名此刻还不存在,投递会失败。
|
||||
*
|
||||
* @param {string} alias
|
||||
* @param {boolean} proposed appendRenameProposal 的返回值
|
||||
* @returns {string} 空串表示没有需要追加的说明
|
||||
*/
|
||||
export function renameProposalNote(alias, proposed) {
|
||||
if (!alias) return '';
|
||||
if (!proposed) {
|
||||
return `(改名提议 "${alias}" 未提交:别名不可为 new,不可含 . 空白 / @ 或双引号。)`;
|
||||
}
|
||||
return `已附上改名提议 "${alias}",等用户在界面上确认后生效 —— 在那之前继续用原别名寻址。`;
|
||||
}
|
||||
@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
@ -50,6 +50,7 @@ import {
|
||||
renderContacts,
|
||||
renderThread,
|
||||
} from '../lib/discovery.js';
|
||||
import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js';
|
||||
|
||||
// ─── 凭证管理 ───
|
||||
|
||||
@ -628,14 +629,27 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
// 的唯一路径。不挂的后果:会话永远落在 Ungrouped,人类在侧栏看不到它
|
||||
// 与项目的从属关系,也无法用 DSH 的工作区级操作(批量归档、重命名等)。
|
||||
//
|
||||
// 失败不可怕:会话本身已经能用了,只是 GUI 分组不对。最常见失败是
|
||||
// 邮件里的 cwd 在 DSH 主机上不存在(跨主机场景),此时 mkdirSync
|
||||
// 建不出目录,workspaceRegistry.create 也报错。
|
||||
if (cwd && handle) {
|
||||
// **cwd 必须取自会话 header,不能用上面那个 `cwd` 变量。**
|
||||
//
|
||||
// 上一版就错在这里:那个变量是 `resolveWorkspaceCwd` 的结果,可能是**兜底值**
|
||||
// (`~/.dsh/mail-sessions/mail-<uuid>`);而 resume 路径下会话的真实 cwd 取自
|
||||
// 持久化的 header,两者不一致时 `attachSession` 的校验直接拒绝:
|
||||
//
|
||||
// cannot attach session 'mail-f3d824ce…' to workspace
|
||||
// '/root/.dsh/mail-sessions/mail-f3d824ce…': its cwd resolves to '/home/program/llmsproxy'
|
||||
//
|
||||
// 更糟的是 `wr.create()` 已经先执行了 —— 于是每封邮件都往注册表里
|
||||
// 塞一条永远为空的垃圾 workspace。先读 header 再注册就不会有这个问题。
|
||||
//
|
||||
// 失败不可怕:会话本身已经能用了,只是 GUI 分组不对。
|
||||
if (handle) {
|
||||
try {
|
||||
// 真实 cwd:create 路径下就是上面传的 meta.cwd,
|
||||
// resume 路径下是持久化 header 里那个。两种情形都从 session 读。
|
||||
const actualCwd = String(handle.agent?.session?.header?.cwd ?? '');
|
||||
const wr: any = (ctx as any).get?.('workspaceRegistry');
|
||||
if (wr?.create) {
|
||||
const ws = await wr.create(cwd, cwd.split('/').pop() || cwd);
|
||||
if (actualCwd && wr?.create) {
|
||||
const ws = await wr.create(actualCwd, actualCwd.split('/').pop() || actualCwd);
|
||||
if (ws?.attachSession) {
|
||||
await ws.attachSession(attemptSessionId as any);
|
||||
}
|
||||
@ -754,14 +768,24 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
reply_to: { type: 'string', description: '回复某封邮件时传其 mail_id' },
|
||||
session_alias: { type: 'string', description: '给新会话命名' },
|
||||
attachment_ids: { type: 'array', items: { type: 'string' }, description: '附件 ID 列表' },
|
||||
propose_alias: {
|
||||
type: 'string',
|
||||
description: '建议把当前会话改名成这个别名(例如摸清问题后从「排查登录问题」改成 fix-session-cookie-leak)。这只是建议:别名是人的寻址入口,实际改名由用户在界面上确认。不可含 . / @ 空白,不可为 new',
|
||||
},
|
||||
propose_reason: { type: 'string', description: '改名理由,一句话,展示给用户看' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args: any, value: string) => [{ type: 'text', text: value }],
|
||||
},
|
||||
async execute(args: any, toolCtx: any): Promise<string> {
|
||||
// 改名提议以 HTML 注释形式附在正文末尾,由网关解析后剥离。
|
||||
// 拼接收在 lib/rename-proposal.js(三平台共用):标记格式是服务端正则的
|
||||
// 镜像,各写一遍的话少个空格就静默失效 —— 邮件照常发出,提议凭空消失。
|
||||
const { body, proposed } = appendRenameProposal(
|
||||
args.body, args.propose_alias, args.propose_reason);
|
||||
const result = await client.post('/mail/send', {
|
||||
to: args.to, subject: args.subject, body: args.body,
|
||||
to: args.to, subject: args.subject, body,
|
||||
cc: args.cc || '', reply_to: args.reply_to || '',
|
||||
session_alias: args.session_alias || '',
|
||||
attachment_ids: args.attachment_ids || [],
|
||||
@ -769,7 +793,10 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
noteExplicitSend(toolCtx?.sessionID, args.to, args.reply_to);
|
||||
const budget = typeof result.budget_remaining === 'number'
|
||||
? ` 本任务剩余 ${result.budget_remaining}/${result.budget_max} 个来回。` : '';
|
||||
return `邮件已发送(ID: ${result.mail_id})${budget}`;
|
||||
// 别名取服务端回的 rename_proposed(它跑过 normalizeAlias),
|
||||
// 回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404
|
||||
const note = renameProposalNote(result.rename_proposed, args.propose_alias, proposed);
|
||||
return `邮件已发送(ID: ${result.mail_id})${budget}` + (note ? `\n${note}` : '');
|
||||
},
|
||||
}));
|
||||
|
||||
@ -813,17 +840,31 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
// upload_attachment
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'upload_attachment',
|
||||
description: '上传本地文件作为邮件附件,返回 attachment_id。',
|
||||
description:
|
||||
'上传本地文件作为邮件附件,返回 attachment_id。' +
|
||||
'拿到 id 后必须在 send_mail 的 attachment_ids 里带上,附件才会随邮件发出。' +
|
||||
'未随邮件发出的附件 24 小时后自动清理。',
|
||||
parameters: {
|
||||
file_path: { type: 'string', required: true, description: '本地文件路径' },
|
||||
file_path: { type: 'string', required: true, description: '要上传的本地文件绝对路径' },
|
||||
filename: { type: 'string', description: '自定义展示文件名,默认取路径的最后一段' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args: any, value: string) => [{ type: 'text', text: value }],
|
||||
},
|
||||
async execute(args: any): Promise<string> {
|
||||
// 先 stat 再读:目录和不存在的路径都要给出能行动的错误。
|
||||
// 直接 readFile 的话,目录抛的 EISDIR 只会让模型重试同一个路径。
|
||||
let st;
|
||||
try {
|
||||
st = await stat(args.file_path);
|
||||
} catch {
|
||||
return `文件不存在或不可读: ${args.file_path}`;
|
||||
}
|
||||
if (!st.isFile()) return `不是普通文件: ${args.file_path}`;
|
||||
|
||||
const data = await readFile(args.file_path);
|
||||
const filename = args.file_path.split('/').pop() || 'file';
|
||||
const filename = args.filename || args.file_path.split('/').pop() || 'file';
|
||||
// **必须发真正的 multipart。**
|
||||
//
|
||||
// 早先这里发的是 `Content-Type: application/octet-stream` 加一个
|
||||
@ -843,17 +884,18 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
const json = await res.json() as any;
|
||||
if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`);
|
||||
const a = json.attachment;
|
||||
return `已上传 ${a.filename}(${formatSize(a.size_bytes)})。attachment_id: ${a.attachment_id}`;
|
||||
return `已上传 ${a.filename}(${formatSize(a.size_bytes)})。attachment_id: ${a.attachment_id}\n` +
|
||||
`在 send_mail 的 attachment_ids 里带上这个 id 才会随邮件发出。`;
|
||||
},
|
||||
}));
|
||||
|
||||
// download_attachment
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'download_attachment',
|
||||
description: '下载邮件附件到本地文件。',
|
||||
description: '下载邮件附件到本地文件。attachment_id 从 read_inbox 的附件清单里取。',
|
||||
parameters: {
|
||||
attachment_id: { type: 'string', required: true, description: '附件 ID' },
|
||||
save_path: { type: 'string', required: true, description: '保存路径' },
|
||||
save_path: { type: 'string', required: true, description: '保存到的本地绝对路径' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
@ -865,6 +907,9 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
});
|
||||
if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
// 父目录不存在时先建:模型经常写 ./downloads/x.pdf 这类还不存在的路径,
|
||||
// 不建的话 writeFile 抛 ENOENT,而那个错误看起来像「附件不存在」。
|
||||
await mkdir(dirname(args.save_path), { recursive: true });
|
||||
await writeFile(args.save_path, buf);
|
||||
return `已保存到 ${args.save_path}(${formatSize(buf.length)})`;
|
||||
},
|
||||
@ -1043,11 +1088,71 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
},
|
||||
}));
|
||||
|
||||
// connect_to_server —— 连接自愈。
|
||||
//
|
||||
// 之前只有 opencode 侧有这个工具。后果是:Gateway 换了地址、或密钥需要
|
||||
// 重新登记时,opencode 里的模型能自己修好,而 DSH 里的模型只能干等
|
||||
// systemd 环境变量被人改 —— 同一类能力在不同平台上时有时无,
|
||||
// 等于让人记住哪个平台能自己修。
|
||||
//
|
||||
// 失败时**把需要登记的密钥全文打出来**:密钥未登记是最常见的失败,
|
||||
// 不给值的话要多走一轮「密钥无效 → 去哪拿 → 让管理员登记」。
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'connect_to_server',
|
||||
description:
|
||||
'连接到 AgentMail Gateway:登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。' +
|
||||
'密钥若未在后台登记过,此处会返回需要登记的密钥全文。',
|
||||
parameters: {
|
||||
gateway_url: { type: 'string', description: 'Gateway 地址,如 https://mail.example.com;省略则用当前配置' },
|
||||
key_token: { type: 'string', description: '管理员签发的 Agent 密钥;省略则用本地密钥(不存在时自动生成)' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args: any, value: string) => [{ type: 'text', text: value }],
|
||||
},
|
||||
async execute(args: any): Promise<string> {
|
||||
let key = client.agentKey;
|
||||
if (args.key_token) {
|
||||
key = String(args.key_token).trim();
|
||||
// 管理员给的密钥落盘,重启后仍然可用
|
||||
saveLocalKey(key);
|
||||
} else if (!key) {
|
||||
key = generateLocalKey();
|
||||
}
|
||||
|
||||
const url = String(args.gateway_url || client.baseURL).replace(/\/+$/, '');
|
||||
|
||||
const res = await fetch(`${url}/api/v1/agent/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
||||
body: JSON.stringify({ name: AGENT_NAME, workspaces: [], platform: 'dsh' }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({})) as any;
|
||||
|
||||
if (!res.ok) {
|
||||
return [
|
||||
`连接失败(HTTP ${res.status}):${data?.error || '未知错误'}`,
|
||||
``,
|
||||
`若提示密钥无效,请让管理员在 AgentMail 后台「Agent 密钥」中登记:`,
|
||||
key,
|
||||
``,
|
||||
`密钥文件:${KEY_FILE}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// 成功后把新坐标写回客户端,当场生效(不用等重启)
|
||||
client.baseURL = url;
|
||||
client.agentKey = key;
|
||||
return `已连接 ${url},注册为 ${data?.agent_name || AGENT_NAME}。`;
|
||||
},
|
||||
}));
|
||||
|
||||
return () => {
|
||||
for (const n of [
|
||||
'send_mail', 'read_inbox', 'read_mail', 'forward_mail',
|
||||
'upload_attachment', 'download_attachment',
|
||||
'suggest_address', 'list_contacts', 'session_participants', 'read_thread',
|
||||
'connect_to_server',
|
||||
]) {
|
||||
try { ctx.tools.unregister(n); } catch {}
|
||||
}
|
||||
|
||||
119
plugins/dsh-mail-bridge/test/rename-proposal.test.mjs
Normal file
119
plugins/dsh-mail-bridge/test/rename-proposal.test.mjs
Normal file
@ -0,0 +1,119 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
isProposableAlias,
|
||||
appendRenameProposal,
|
||||
renameProposalNote,
|
||||
} from '../lib/rename-proposal.js';
|
||||
|
||||
// 这一组测试钉住的是「插件拼的标记与服务端正则逐字符对应」。
|
||||
// 服务端那条正则在 gateway/internal/handler/rename_proposal.go:
|
||||
// <!--\s*agentmail:rename-session\s+alias="([^"]*)"(?:\s+reason="([^"]*)")?\s*-->
|
||||
// 拼错不会报错 —— 邮件照常发出,提议凭空消失。
|
||||
|
||||
/** 服务端正则的等价实现,用来验证我们拼出来的标记真的能被摘出来。 */
|
||||
const SERVER_RE =
|
||||
/<!--\s*agentmail:rename-session\s+alias="([^"]*)"(?:\s+reason="([^"]*)")?\s*-->/s;
|
||||
|
||||
test('isProposableAlias: 合法别名', () => {
|
||||
assert.equal(isProposableAlias('fix-login-leak'), true);
|
||||
assert.equal(isProposableAlias('修复登录态泄漏'), true, '中文别名合法');
|
||||
assert.equal(isProposableAlias('v2_migration'), true);
|
||||
});
|
||||
|
||||
test('isProposableAlias: new 是寻址保留字', () => {
|
||||
// `.new` 是「强制新建会话」的动作,别名叫 new 会让地址无从解释
|
||||
assert.equal(isProposableAlias('new'), false);
|
||||
});
|
||||
|
||||
test('isProposableAlias: 拒绝与三维地址冲突的字符', () => {
|
||||
// 这四个字符都会让 name@path.session 的切分产生歧义
|
||||
assert.equal(isProposableAlias('a.b'), false, '. 是 session 位分隔符');
|
||||
assert.equal(isProposableAlias('a/b'), false, '/ 出现在 path 位');
|
||||
assert.equal(isProposableAlias('a@b'), false, '@ 是 name/path 分隔符');
|
||||
assert.equal(isProposableAlias('a b'), false, '空白');
|
||||
assert.equal(isProposableAlias('a\tb'), false, '制表符也算空白');
|
||||
});
|
||||
|
||||
test('isProposableAlias: 拒绝双引号', () => {
|
||||
// 双引号是标记自身的定界符,含它会把标记截断成非法形式
|
||||
assert.equal(isProposableAlias('say"hi'), false);
|
||||
});
|
||||
|
||||
test('isProposableAlias: 空与空白视为没提', () => {
|
||||
assert.equal(isProposableAlias(''), false);
|
||||
assert.equal(isProposableAlias(' '), false);
|
||||
assert.equal(isProposableAlias(undefined), false);
|
||||
assert.equal(isProposableAlias(null), false);
|
||||
});
|
||||
|
||||
test('isProposableAlias: 超过 128 字节按字节算', () => {
|
||||
// 服务端是 VARCHAR(128)。中文一个字 3 字节,43 字 = 129 字节
|
||||
assert.equal(isProposableAlias('a'.repeat(128)), true);
|
||||
assert.equal(isProposableAlias('a'.repeat(129)), false);
|
||||
assert.equal(isProposableAlias('汉'.repeat(42)), true, '126 字节');
|
||||
assert.equal(isProposableAlias('汉'.repeat(43)), false, '129 字节');
|
||||
});
|
||||
|
||||
test('标记能被服务端正则摘出来', () => {
|
||||
const { body, proposed } = appendRenameProposal('已定位到问题。', 'fix-login-leak', '登录态泄漏');
|
||||
assert.equal(proposed, true);
|
||||
const m = SERVER_RE.exec(body);
|
||||
assert.ok(m, '服务端正则必须匹配得上');
|
||||
assert.equal(m[1], 'fix-login-leak');
|
||||
assert.equal(m[2], '登录态泄漏');
|
||||
});
|
||||
|
||||
test('没有理由时整个 reason 属性都不写', () => {
|
||||
// 写成 reason="" 会让服务端存一个空理由,界面提示条就少了那句解释
|
||||
const { body } = appendRenameProposal('正文', 'fix-leak');
|
||||
assert.doesNotMatch(body, /reason=/);
|
||||
const m = SERVER_RE.exec(body);
|
||||
assert.equal(m[1], 'fix-leak');
|
||||
assert.equal(m[2], undefined);
|
||||
});
|
||||
|
||||
test('理由里的双引号被去掉而不是转义', () => {
|
||||
// HTML 注释里没有转义机制,留着会截断标记
|
||||
const { body } = appendRenameProposal('正文', 'fix-leak', '他说"这是泄漏"');
|
||||
const m = SERVER_RE.exec(body);
|
||||
assert.ok(m);
|
||||
assert.equal(m[2], '他说这是泄漏');
|
||||
});
|
||||
|
||||
test('原正文完整保留在标记之前', () => {
|
||||
const original = '第一行\n\n第二行';
|
||||
const { body } = appendRenameProposal(original, 'fix-leak');
|
||||
assert.ok(body.startsWith(original), '正文不得被改写');
|
||||
});
|
||||
|
||||
test('别名不合法时原样返回,不追加标记', () => {
|
||||
const { body, proposed } = appendRenameProposal('正文', 'a.b');
|
||||
assert.equal(proposed, false);
|
||||
assert.equal(body, '正文');
|
||||
assert.doesNotMatch(body, /agentmail:rename-session/);
|
||||
});
|
||||
|
||||
test('不给别名时正文完全不变', () => {
|
||||
const { body, proposed } = appendRenameProposal('正文', '');
|
||||
assert.equal(proposed, false);
|
||||
assert.equal(body, '正文');
|
||||
});
|
||||
|
||||
test('renameProposalNote: 成功时必须说明等人确认', () => {
|
||||
// 不说的话模型会以为改名已生效,接着用新别名当地址发信 —— 那个别名还不存在
|
||||
const note = renameProposalNote('fix-leak', true);
|
||||
assert.match(note, /fix-leak/);
|
||||
assert.match(note, /确认/);
|
||||
assert.match(note, /原别名/, '要明确说在那之前用哪个');
|
||||
});
|
||||
|
||||
test('renameProposalNote: 失败时说清为什么', () => {
|
||||
const note = renameProposalNote('a.b', false);
|
||||
assert.match(note, /未提交/);
|
||||
assert.match(note, /a\.b/);
|
||||
});
|
||||
|
||||
test('renameProposalNote: 没提议时不产生噪音', () => {
|
||||
assert.equal(renameProposalNote('', false), '');
|
||||
});
|
||||
@ -28,13 +28,21 @@ const (
|
||||
// 因此本插件不做会话映射 —— 所有邮件注入同一个事件循环,像 QQ 插件一样。
|
||||
// 邮件的 context 完全靠中断消息的文本传递,不靠 platform_sessions 上报。
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
gwURL string
|
||||
key string
|
||||
client *http.Client
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
// name 是**插件名**(homed 注册用,如 homeagent-mail-bridge)。
|
||||
name string
|
||||
// agentName 是**AgentMail 身份**(如 homeagent)。
|
||||
//
|
||||
// 两者必须分开:密钥绑定的是 AgentMail 身份,拿插件名去注册会被拒
|
||||
// 403 该密钥已绑定到 Agent "homeagent",不能用于注册 "homeagent-mail-bridge"
|
||||
// 这不是 Gateway 太严格 —— 名字与人类用户名共用命名空间,
|
||||
// 让一把密钥能注册任意名字等于让它能冒充任何人。
|
||||
agentName string
|
||||
sdk *sdk.PluginSDK
|
||||
gwURL string
|
||||
key string
|
||||
client *http.Client
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
@ -55,12 +63,28 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
|
||||
if key == "" {
|
||||
key = os.Getenv("AGENTMAIL_AGENT_KEY")
|
||||
}
|
||||
|
||||
// AgentMail 身份:config > 环境变量 > 从插件名去掉 -mail-bridge 后缀。
|
||||
// 兜底那条让默认配置能直接跑通(homeagent-mail-bridge → homeagent),
|
||||
// 但显式配置永远优先 —— 插件名是部署细节,不该决定对外身份。
|
||||
agentName := ""
|
||||
if v, ok := config["agent_name"].(string); ok {
|
||||
agentName = strings.TrimSpace(v)
|
||||
}
|
||||
if agentName == "" {
|
||||
agentName = strings.TrimSpace(os.Getenv("AGENTMAIL_AGENT_NAME"))
|
||||
}
|
||||
if agentName == "" {
|
||||
agentName = strings.TrimSuffix(name, "-mail-bridge")
|
||||
}
|
||||
|
||||
return &Plugin{
|
||||
name: name,
|
||||
gwURL: strings.TrimRight(gw, "/"),
|
||||
key: key,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
stopCh: make(chan struct{}),
|
||||
name: name,
|
||||
agentName: agentName,
|
||||
gwURL: strings.TrimRight(gw, "/"),
|
||||
key: key,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
stopCh: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -106,6 +130,94 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
},
|
||||
}, p.handleSendMail)
|
||||
|
||||
// 读一封的完整内容(收件箱只给摘要;要回给抄收方就得先看清发给了谁)
|
||||
s.RegisterTool("read_mail", sdk.ToolDef{
|
||||
Name: "read_mail",
|
||||
Description: "读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。",
|
||||
Parameters: oneStringParam("mail_id", "邮件 ID", true),
|
||||
}, p.handleReadMail)
|
||||
|
||||
// 转发 —— 引用原文与附件,按目标地址另行定位会话(它是一条新线索)
|
||||
s.RegisterTool("forward_mail", sdk.ToolDef{
|
||||
Name: "forward_mail",
|
||||
Description: "转发一封邮件给新的收件人(自动引用原文与附件)。与回复不同:回复落回原会话,转发按目标地址另行定位会话。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"mail_id": map[string]interface{}{"type": "string", "description": "要转发的邮件 ID"},
|
||||
"to": map[string]interface{}{"type": "string", "description": "新收件人的三维地址(先用 suggest_address 确认)"},
|
||||
"comment": map[string]interface{}{"type": "string", "description": "转发说明,置于引用原文之前"},
|
||||
"cc": map[string]interface{}{"type": "string", "description": "抄送,逗号分隔多个三维地址"},
|
||||
"subject": map[string]interface{}{"type": "string", "description": "自定义主题;留空则自动加 Fwd: 前缀"},
|
||||
"session_alias": map[string]interface{}{"type": "string", "description": "仅当目标地址以 .new 结尾时生效:给新会话命名"},
|
||||
},
|
||||
"required": []string{"mail_id", "to"},
|
||||
},
|
||||
}, p.handleForwardMail)
|
||||
|
||||
// ─── 寻址发现 ───
|
||||
//
|
||||
// 没有这一组时,send_mail 的 to 是个只能靠记忆拼写的自由文本字段,
|
||||
// 而拼错不报错:生产上另一个平台猜了 `opencode@/home`,投递成功,
|
||||
// 但那不是它的工作目录,错误路径静默变成了新会话的 workspace。
|
||||
|
||||
s.RegisterTool("suggest_address", sdk.ToolDef{
|
||||
Name: "suggest_address",
|
||||
Description: "查询可用的收件人地址。不带参数给候选收件人名;带 name 给它可用的工作目录;" +
|
||||
"name+path 都带则给该目录下可续谈的会话与现成地址。**发信前应先用它确认地址**,不要凭记忆拼写。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string", "description": "收件人名;留空则列出所有候选收件人"},
|
||||
"path": map[string]interface{}{"type": "string", "description": "工作目录;与 name 同时给出才列会话"},
|
||||
},
|
||||
},
|
||||
}, p.handleSuggestAddress)
|
||||
|
||||
s.RegisterTool("list_contacts", sdk.ToolDef{
|
||||
Name: "list_contacts",
|
||||
Description: "列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。用于回答「我还有什么没处理」。",
|
||||
Parameters: oneStringParam("limit", "最多列出多少条,默认 20", false),
|
||||
}, p.handleListContacts)
|
||||
|
||||
s.RegisterTool("session_participants", sdk.ToolDef{
|
||||
Name: "session_participants",
|
||||
Description: "列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址,并标出谁还没回应。" +
|
||||
"**要回给抄收方或向第三方转达时先用它拿地址**。",
|
||||
Parameters: oneStringParam("session_id", "会话 ID", true),
|
||||
}, p.handleSessionParticipants)
|
||||
|
||||
s.RegisterTool("read_thread", sdk.ToolDef{
|
||||
Name: "read_thread",
|
||||
Description: "查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方抄送协作时用它确认" +
|
||||
"别人已经说了什么,避免重复提问或重复汇报。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"mail_id": map[string]interface{}{"type": "string", "description": "线索中任一封邮件的 ID"},
|
||||
"offset": map[string]interface{}{"type": "number", "description": "分页偏移,续取时传上次返回的 next_offset"},
|
||||
},
|
||||
"required": []string{"mail_id"},
|
||||
},
|
||||
}, p.handleReadThread)
|
||||
|
||||
// connect_to_server —— 连接自愈。
|
||||
//
|
||||
// Gateway 换了地址、或密钥需要重新登记时,模型能自己修好而不必等人改
|
||||
// 环境变量。失败时把需要登记的密钥全文打出来,省掉一轮来回。
|
||||
s.RegisterTool("connect_to_server", sdk.ToolDef{
|
||||
Name: "connect_to_server",
|
||||
Description: "连接到 AgentMail Gateway:登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。" +
|
||||
"密钥若未在后台登记过,此处会返回需要登记的密钥全文。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"gateway_url": map[string]interface{}{"type": "string", "description": "Gateway 地址;省略则用当前配置"},
|
||||
"key_token": map[string]interface{}{"type": "string", "description": "管理员签发的 Agent 密钥;省略则用当前密钥"},
|
||||
},
|
||||
},
|
||||
}, p.handleConnectToServer)
|
||||
|
||||
// 注册输出通道 —— agent 可以主动调 output_send__homeagent 发信
|
||||
s.RegisterOutputChannel("homeagent", sdk.CapText|sdk.CapFile,
|
||||
"发送邮件。meta JSON 格式:{to, subject, reply_to},type: text",
|
||||
@ -148,7 +260,7 @@ func (p *Plugin) heartbeatLoop() {
|
||||
|
||||
func (p *Plugin) register() error {
|
||||
body := map[string]interface{}{
|
||||
"name": p.name,
|
||||
"name": p.agentName,
|
||||
"platform": "homeagent",
|
||||
"workspaces": []interface{}{},
|
||||
}
|
||||
@ -337,7 +449,7 @@ func (p *Plugin) handleNewMail(evt struct {
|
||||
"**回信不用你自己发**:你把本轮工作做完、把结论说出来就行,\n"+
|
||||
"插件会在这一轮结束时自动把你最后那段话作为回信发回给 %s(不消耗你的发信配额)。\n"+
|
||||
"只有在需要主动联系其他人、或要带附件时才调用 send_mail。",
|
||||
evt.FromName, evt.Subject, evt.MailID, p.name, evt.FromName,
|
||||
evt.FromName, evt.Subject, evt.MailID, p.agentName, evt.FromName,
|
||||
)
|
||||
|
||||
// 非阻塞注入 —— TrueAgent 的事件循环会处理
|
||||
@ -487,9 +599,9 @@ func (p *Plugin) handleSendMail(args map[string]interface{}) (interface{}, error
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"to": to,
|
||||
"subject": subj,
|
||||
"body": body,
|
||||
"to": to,
|
||||
"subject": subj,
|
||||
"body": body,
|
||||
}
|
||||
if cc != "" {
|
||||
payload["cc"] = cc
|
||||
|
||||
460
plugins/homeagent-mail-bridge/tools.go
Normal file
460
plugins/homeagent-mail-bridge/tools.go
Normal file
@ -0,0 +1,460 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ─── 工具定义(与 opencode/dsh/pi 同源逻辑,Go 版本)───
|
||||
//
|
||||
// 所有工具都是对 Gateway REST API 的薄封装:HTTP → 渲染 → 模型可读文本。
|
||||
// 与 JS 插件的区别仅在 HTTP 辅助函数(p.get / p.post),行为完全一致。
|
||||
|
||||
func (p *Plugin) handleReadMail(args map[string]interface{}) (interface{}, error) {
|
||||
mid, _ := args["mail_id"].(string)
|
||||
if mid == "" {
|
||||
return nil, fmt.Errorf("缺少 mail_id")
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Mail struct {
|
||||
FromName string `json:"from_name"`
|
||||
ToName string `json:"to_name"`
|
||||
ToWorkspace string `json:"to_workspace"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
CCList []struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Raw string `json:"raw"`
|
||||
} `json:"cc_list"`
|
||||
Attachments []struct {
|
||||
Filename string `json:"filename"`
|
||||
SizeBytes int `json:"size_bytes"`
|
||||
AttachmentID string `json:"attachment_id"`
|
||||
} `json:"attachments"`
|
||||
} `json:"mail"`
|
||||
SessionAlias string `json:"session_alias"`
|
||||
ReplyAddress string `json:"reply_address"`
|
||||
SelfAddress string `json:"self_address"`
|
||||
Participants []struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Roles []string `json:"roles"`
|
||||
Address string `json:"address"`
|
||||
IsSelf bool `json:"is_self"`
|
||||
} `json:"participants"`
|
||||
}
|
||||
if err := p.get(p.gwURL+"/api/v1/agent/mail/"+mid, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "发件人: %s\n", data.Mail.FromName)
|
||||
if data.Mail.ToWorkspace != "" {
|
||||
fmt.Fprintf(&sb, "收件人: %s@%s\n", data.Mail.ToName, data.Mail.ToWorkspace)
|
||||
} else {
|
||||
fmt.Fprintf(&sb, "收件人: %s\n", data.Mail.ToName)
|
||||
}
|
||||
fmt.Fprintf(&sb, "主题: %s\n", data.Mail.Subject)
|
||||
fmt.Fprintf(&sb, "会话: #%s(session_id: %s)\n", data.SessionAlias, data.Mail.FromName)
|
||||
|
||||
if len(data.Mail.CCList) > 0 {
|
||||
names := make([]string, 0, len(data.Mail.CCList))
|
||||
for _, c := range data.Mail.CCList {
|
||||
names = append(names, c.Raw)
|
||||
}
|
||||
fmt.Fprintf(&sb, "抄送: %s\n", strings.Join(names, "、"))
|
||||
}
|
||||
if len(data.Mail.Attachments) > 0 {
|
||||
fmt.Fprintf(&sb, "附件:\n")
|
||||
for _, a := range data.Mail.Attachments {
|
||||
fmt.Fprintf(&sb, " - %s (%.1fKB, id=%s)\n", a.Filename, float64(a.SizeBytes)/1024, a.AttachmentID)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&sb, "\n%s\n", data.Mail.Body)
|
||||
|
||||
if len(data.Participants) > 0 {
|
||||
fmt.Fprintf(&sb, "\n可投递地址:\n")
|
||||
for _, pt := range data.Participants {
|
||||
if pt.Address != "" && !pt.IsSelf {
|
||||
fmt.Fprintf(&sb, " - %s (%s)\n", pt.Address, strings.Join(pt.Roles, "/"))
|
||||
}
|
||||
}
|
||||
}
|
||||
if data.ReplyAddress != "" {
|
||||
fmt.Fprintf(&sb, "回信给发件人用 %s,或传 reply_to=%s\n", data.ReplyAddress, data.Mail.FromName)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": []map[string]interface{}{{"type": "text", "text": sb.String()}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleForwardMail(args map[string]interface{}) (interface{}, error) {
|
||||
mid, _ := args["mail_id"].(string)
|
||||
to, _ := args["to"].(string)
|
||||
comment, _ := args["comment"].(string)
|
||||
cc, _ := args["cc"].(string)
|
||||
subj, _ := args["subject"].(string)
|
||||
sa, _ := args["session_alias"].(string)
|
||||
|
||||
if mid == "" || to == "" {
|
||||
return nil, fmt.Errorf("缺少 mail_id 和 to")
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"to": to,
|
||||
"comment": comment,
|
||||
"cc": cc,
|
||||
"subject": subj,
|
||||
"session_alias": sa,
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := p.post("/mail/"+mid+"/forward", payload, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
text := fmt.Sprintf("已转发。新 Mail ID: %s,Session: %s", result["mail_id"], result["session_id"])
|
||||
return map[string]interface{}{
|
||||
"content": []map[string]interface{}{{"type": "text", "text": text}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleSuggestAddress(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
path, _ := args["path"].(string)
|
||||
name = strings.TrimSpace(name)
|
||||
path = strings.TrimSpace(path)
|
||||
|
||||
qs := ""
|
||||
if name != "" {
|
||||
qs += "name=" + name
|
||||
}
|
||||
if path != "" {
|
||||
if qs != "" {
|
||||
qs += "&"
|
||||
}
|
||||
qs += "path=" + path
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Kind string `json:"kind"`
|
||||
Suggestions []string `json:"suggestions"`
|
||||
Addresses []string `json:"addresses"`
|
||||
Candidates []struct {
|
||||
Alias string `json:"alias"`
|
||||
Title string `json:"title"`
|
||||
Unread int `json:"unread"`
|
||||
Source string `json:"source"`
|
||||
} `json:"candidates"`
|
||||
}
|
||||
if err := p.get(p.gwURL+"/api/v1/agent/contacts/suggest?"+qs, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
switch data.Kind {
|
||||
case "name":
|
||||
sb.WriteString(fmt.Sprintf("可投递的收件人(%d 个):\n", len(data.Suggestions)))
|
||||
for _, n := range data.Suggestions {
|
||||
fmt.Fprintf(&sb, "- %s\n", n)
|
||||
}
|
||||
sb.WriteString("\n下一步:用 suggest_address 带上 name 查它可用的工作目录(path 位)。")
|
||||
|
||||
case "path":
|
||||
if len(data.Suggestions) == 0 {
|
||||
fmt.Fprintf(&sb, "%s 没有记录在案的工作目录。\npath 位可以留空。", name)
|
||||
} else {
|
||||
fmt.Fprintf(&sb, "%s 用过的工作目录(按最近使用排序):\n", name)
|
||||
for _, p := range data.Suggestions {
|
||||
fmt.Fprintf(&sb, "- %s\n", p)
|
||||
}
|
||||
}
|
||||
default:
|
||||
// session
|
||||
existing := 0
|
||||
for _, a := range data.Suggestions {
|
||||
if a != "new" {
|
||||
existing++
|
||||
}
|
||||
}
|
||||
if existing == 0 {
|
||||
fmt.Fprintf(&sb, "%s@%s 下还没有可续谈的会话。", name, path)
|
||||
} else {
|
||||
fmt.Fprintf(&sb, "%s@%s 下可续谈的会话:\n", name, path)
|
||||
for i, alias := range data.Suggestions {
|
||||
if alias == "new" {
|
||||
continue
|
||||
}
|
||||
addr := ""
|
||||
if i < len(data.Addresses) {
|
||||
addr = data.Addresses[i]
|
||||
}
|
||||
fmt.Fprintf(&sb, "- %s\n", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": []map[string]interface{}{{"type": "text", "text": sb.String()}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleListContacts(args map[string]interface{}) (interface{}, error) {
|
||||
limit := 20
|
||||
if v, ok := args["limit"].(float64); ok && v > 0 {
|
||||
limit = int(v)
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Contacts []struct {
|
||||
Address string `json:"address"`
|
||||
Subject string `json:"subject"`
|
||||
Unread int `json:"unread_count"`
|
||||
MaxRounds int `json:"max_rounds"`
|
||||
UsedRounds int `json:"used_rounds"`
|
||||
Alias string `json:"session_alias"`
|
||||
} `json:"contacts"`
|
||||
}
|
||||
if err := p.get(p.gwURL+"/api/v1/agent/contacts", &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(data.Contacts) == 0 {
|
||||
return map[string]interface{}{
|
||||
"content": []map[string]interface{}{{"type": "text", "text": "还没有任何往来会话。"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 按未读优先排序
|
||||
for i := 0; i < len(data.Contacts)-1; i++ {
|
||||
for j := i + 1; j < len(data.Contacts); j++ {
|
||||
if data.Contacts[j].Unread > data.Contacts[i].Unread {
|
||||
data.Contacts[i], data.Contacts[j] = data.Contacts[j], data.Contacts[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
n := limit
|
||||
if n > len(data.Contacts) {
|
||||
n = len(data.Contacts)
|
||||
}
|
||||
fmt.Fprintf(&sb, "往来会话(共 %d 条):\n", len(data.Contacts))
|
||||
for _, c := range data.Contacts[:n] {
|
||||
bits := []string{}
|
||||
if c.Unread > 0 {
|
||||
bits = append(bits, fmt.Sprintf("%d 封未读", c.Unread))
|
||||
}
|
||||
if c.Subject != "" {
|
||||
bits = append(bits, c.Subject)
|
||||
}
|
||||
if c.MaxRounds > 0 {
|
||||
left := c.MaxRounds - c.UsedRounds
|
||||
if left < 0 {
|
||||
left = 0
|
||||
}
|
||||
bits = append(bits, fmt.Sprintf("剩 %d/%d 个来回", left, c.MaxRounds))
|
||||
}
|
||||
extra := ""
|
||||
if len(bits) > 0 {
|
||||
extra = fmt.Sprintf(" (%s)", strings.Join(bits, ","))
|
||||
}
|
||||
fmt.Fprintf(&sb, "- %s%s\n", c.Address, extra)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": []map[string]interface{}{{"type": "text", "text": sb.String()}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleSessionParticipants(args map[string]interface{}) (interface{}, error) {
|
||||
sid, _ := args["session_id"].(string)
|
||||
if sid == "" {
|
||||
return nil, fmt.Errorf("缺少 session_id")
|
||||
}
|
||||
|
||||
var data struct {
|
||||
SessionAlias string `json:"session_alias"`
|
||||
Participants []struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Roles []string `json:"roles"`
|
||||
IsSelf bool `json:"is_self"`
|
||||
MailCount int `json:"mail_count"`
|
||||
Address string `json:"address"`
|
||||
} `json:"participants"`
|
||||
}
|
||||
if err := p.get(p.gwURL+"/api/v1/agent/sessions/"+sid+"/participants", &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(data.Participants) == 0 {
|
||||
return map[string]interface{}{
|
||||
"content": []map[string]interface{}{{"type": "text", "text": "该会话还没有参与方。"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "会话 #%s 的参与方:\n", data.SessionAlias)
|
||||
for _, pt := range data.Participants {
|
||||
tags := []string{}
|
||||
if pt.IsSelf {
|
||||
tags = append(tags, "就是你")
|
||||
}
|
||||
if len(pt.Roles) > 0 {
|
||||
tags = append(tags, strings.Join(pt.Roles, "/"))
|
||||
}
|
||||
if pt.MailCount == 0 && !pt.IsSelf {
|
||||
tags = append(tags, "尚未回应")
|
||||
}
|
||||
extra := ""
|
||||
if len(tags) > 0 {
|
||||
extra = fmt.Sprintf(" [%s]", strings.Join(tags, ","))
|
||||
}
|
||||
fmt.Fprintf(&sb, "- %s %s%s\n", pt.Name, pt.Address, extra)
|
||||
}
|
||||
sb.WriteString("\n要联系其中某一方,把它的地址原样填进 send_mail 的 to。")
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": []map[string]interface{}{{"type": "text", "text": sb.String()}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleReadThread(args map[string]interface{}) (interface{}, error) {
|
||||
mid, _ := args["mail_id"].(string)
|
||||
if mid == "" {
|
||||
return nil, fmt.Errorf("缺少 mail_id")
|
||||
}
|
||||
offset := ""
|
||||
if v, ok := args["offset"].(float64); ok && v > 0 {
|
||||
offset = fmt.Sprintf("?offset=%d", int(v))
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Total int `json:"total"`
|
||||
Hidden int `json:"hidden"`
|
||||
HasMore bool `json:"has_more"`
|
||||
NextOff int `json:"next_offset"`
|
||||
AnchorID string `json:"anchor_mail_id"`
|
||||
Nodes []struct {
|
||||
MailID string `json:"mail_id"`
|
||||
FromName string `json:"from_name"`
|
||||
ToName string `json:"to_name"`
|
||||
Subject string `json:"subject"`
|
||||
Depth int `json:"depth"`
|
||||
Detached bool `json:"detached"`
|
||||
ParentHid bool `json:"parent_hidden"`
|
||||
} `json:"nodes"`
|
||||
}
|
||||
if err := p.get(p.gwURL+"/api/v1/agent/mail/"+mid+"/thread"+offset, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(data.Nodes) == 0 {
|
||||
return map[string]interface{}{
|
||||
"content": []map[string]interface{}{{"type": "text", "text": "这条线索上没有可见的邮件。"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "线索共 %d 封", data.Total)
|
||||
if data.Hidden > 0 {
|
||||
fmt.Fprintf(&sb, "(另有 %d 封无权查看)", data.Hidden)
|
||||
}
|
||||
sb.WriteString(":\n")
|
||||
|
||||
for _, n := range data.Nodes {
|
||||
indent := ""
|
||||
if n.Depth > 0 {
|
||||
indent = strings.Repeat(" ", min(n.Depth, 8))
|
||||
}
|
||||
marks := []string{}
|
||||
if n.MailID == data.AnchorID {
|
||||
marks = append(marks, "当前这封")
|
||||
}
|
||||
if n.Detached {
|
||||
if n.ParentHid {
|
||||
marks = append(marks, "父邮件无权查看")
|
||||
} else {
|
||||
marks = append(marks, "父邮件尚未加载")
|
||||
}
|
||||
}
|
||||
extra := ""
|
||||
if len(marks) > 0 {
|
||||
extra = fmt.Sprintf(" (%s)", strings.Join(marks, ","))
|
||||
}
|
||||
fmt.Fprintf(&sb, "%s- %s → %s: %s [%s]%s\n",
|
||||
indent, n.FromName, n.ToName, n.Subject, n.MailID, extra)
|
||||
}
|
||||
if data.HasMore {
|
||||
fmt.Fprintf(&sb, "\n还有更多,用 offset=%d 继续取。\n", data.NextOff)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": []map[string]interface{}{{"type": "text", "text": sb.String()}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// oneStringParam 给只有一个参数的工具生成 schema。
|
||||
//
|
||||
// 单独提出来不是为了省字数,而是因为手写 JSON Schema 字面量很容易漏掉
|
||||
// `"type": "object"` 或把 required 写成字符串而不是数组 —— 那类错误不会
|
||||
// 在编译期暴露,而是让模型收到一个它无法调用的工具。
|
||||
func oneStringParam(name, desc string, required bool) map[string]interface{} {
|
||||
schema := map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
name: map[string]interface{}{"type": "string", "description": desc},
|
||||
},
|
||||
}
|
||||
if required {
|
||||
schema["required"] = []string{name}
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
// handleConnectToServer 重新登记密钥并注册。
|
||||
//
|
||||
// 成功后把新坐标写回 p,当场生效 —— 不用等重启。这是这个工具存在的全部意义:
|
||||
// 若还要重启才生效,人直接改环境变量就行了,不需要给模型一个工具。
|
||||
func (p *Plugin) handleConnectToServer(args map[string]interface{}) (interface{}, error) {
|
||||
url := p.gwURL
|
||||
if v, ok := args["gateway_url"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
url = strings.TrimRight(strings.TrimSpace(v), "/")
|
||||
}
|
||||
key := p.key
|
||||
if v, ok := args["key_token"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
key = strings.TrimSpace(v)
|
||||
}
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("没有可用的密钥:请传 key_token,或在 AGENTMAIL_AGENT_KEY 环境变量里配置")
|
||||
}
|
||||
|
||||
// 用候选坐标试注册,成功了才写回 —— 失败时不该把原本能用的配置改坏
|
||||
probe := &Plugin{agentName: p.agentName, gwURL: url, key: key, client: p.client}
|
||||
if err := probe.register(); err != nil {
|
||||
return map[string]interface{}{
|
||||
"content": []map[string]interface{}{{"type": "text", "text": strings.Join([]string{
|
||||
fmt.Sprintf("连接失败:%v", err),
|
||||
"",
|
||||
"若提示密钥无效,请让管理员在 AgentMail 后台「Agent 密钥」中登记:",
|
||||
key,
|
||||
}, "\n")}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
p.gwURL = url
|
||||
p.key = key
|
||||
return map[string]interface{}{
|
||||
"content": []map[string]interface{}{{"type": "text", "text": fmt.Sprintf("已连接 %s,注册为 %s。", url, p.agentName)}},
|
||||
}, nil
|
||||
}
|
||||
@ -33,6 +33,7 @@ import {
|
||||
noteExplicitSend,
|
||||
shouldSkipAutoRelay,
|
||||
} from "./lib/relay-dedup.js";
|
||||
import { appendRenameProposal, renameProposalNote } from "./lib/rename-proposal.js";
|
||||
|
||||
const GATEWAY_URL = process.env.AGENTMAIL_GATEWAY_URL || "http://127.0.0.1:8180";
|
||||
const AGENT_NAME = process.env.AGENTMAIL_AGENT_NAME || "opencode";
|
||||
@ -170,14 +171,11 @@ const sendMailTool = {
|
||||
// 让 session.idle 的自动转发让位,避免同一件事发两封。
|
||||
async execute(args, context) {
|
||||
// 改名建议以 HTML 注释形式附在正文末尾,由网关解析后剥离。
|
||||
// 选注释而不是自造标记:react-markdown 不解析 raw HTML,
|
||||
// 万一网关没剥掉,它在页面上也只是一行不显眼的转义文本而非破版内容。
|
||||
let body = args.body;
|
||||
if (args.propose_alias) {
|
||||
const esc = (v) => String(v).replace(/"/g, ""); // 双引号是标记的定界符
|
||||
const reason = args.propose_reason ? ` reason="${esc(args.propose_reason)}"` : "";
|
||||
body += `\n\n<!-- agentmail:rename-session alias="${esc(args.propose_alias)}"${reason} -->`;
|
||||
}
|
||||
// 标记格式是服务端正则的镜像,因此拼接收在 lib/rename-proposal.js
|
||||
// (三平台共用)—— 各写一遍的话少个空格就静默失效:邮件照常发出,
|
||||
// 提议凭空消失,而模型以为自己提过了。
|
||||
const { body, proposed } = appendRenameProposal(
|
||||
args.body, args.propose_alias, args.propose_reason);
|
||||
|
||||
const result = await apiPost("/mail/send", {
|
||||
to: args.to,
|
||||
@ -205,11 +203,11 @@ const sendMailTool = {
|
||||
? "预算即将用尽,请尽快给出结论;自动转发的总结不占预算。"
|
||||
: "")
|
||||
: "";
|
||||
// 回传规范化后的别名:Agent 提的名字可能含非法字符被改写过
|
||||
const proposed = result.rename_proposed
|
||||
? `\n已向用户提议把会话改名为 ${result.rename_proposed},等待其确认。`
|
||||
: "";
|
||||
return `已发送。Mail ID: ${result.mail_id},Session: ${result.session_id}${alias}${budget}${proposed}`;
|
||||
// 别名取服务端回的 rename_proposed:它跑过 normalizeAlias,
|
||||
// 回显本地值会让模型记住一个不存在的名字
|
||||
const note = renameProposalNote(result.rename_proposed, args.propose_alias, proposed);
|
||||
return `已发送。Mail ID: ${result.mail_id},Session: ${result.session_id}${alias}${budget}` +
|
||||
(note ? `\n${note}` : "");
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
130
plugins/opencode-mail-bridge/lib/rename-proposal.js
Normal file
130
plugins/opencode-mail-bridge/lib/rename-proposal.js
Normal file
@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 会话改名提议 —— 所有平台插件共用。
|
||||
*
|
||||
* # 这是什么
|
||||
*
|
||||
* 模型干完活后可能觉得当前别名不贴切:会话建立时叫 `witty-planet`(平台随机 slug)
|
||||
* 或 `排查登录问题`(人写的邮件主题),摸清问题后它知道这其实是
|
||||
* `fix-session-cookie-leak`。改名提议就是让它把这个判断说出来。
|
||||
*
|
||||
* # 为什么是「提议」而不是直接改
|
||||
*
|
||||
* 别名是**人**的寻址入口 —— `name@path.<别名>` 里那一段。Agent 干到一半自己改掉,
|
||||
* 人上一秒记住的地址下一秒就 404(`session` 位三态语义要求指向不存在的会话直接报
|
||||
* 「无法送达」,不会静默新建)。所以提议入库、由人在界面上点「接受」才真正生效。
|
||||
*
|
||||
* 这与平台命名自动同步(`POST /sessions/{id}/sync`)互补,两者不冲突:
|
||||
*
|
||||
* | | 谁发起 | 何时 | 是否打扰人 |
|
||||
* |---|---|---|---|
|
||||
* | 自动同步 | 平台的命名机制 | 每轮结束 | 不,后台静默生效 |
|
||||
* | 改名提议 | 模型的主动判断 | 它认为有必要时 | 是,界面上出提示条 |
|
||||
*
|
||||
* # 为什么载体是 HTML 注释
|
||||
*
|
||||
* `/mail/send` 没有 `propose_alias` 字段 —— 提议**搭在正文里**发出去,
|
||||
* 服务端用正则摘出来再把标记从入库正文中剥掉。选 HTML 注释的三个理由:
|
||||
*
|
||||
* - react-markdown 默认不解析 raw HTML,万一服务端没剥掉,它在页面上也只是
|
||||
* 一行不显眼的转义文本,不会破版
|
||||
* - 纯文本邮件客户端里是一行不碍事的注释,不像自造标记那样显眼
|
||||
* - 不与 Markdown 语法冲突,格式化工具不会改写它
|
||||
*
|
||||
* # 为什么必须共用
|
||||
*
|
||||
* 标记格式是**服务端正则的镜像**(`gateway/internal/handler/rename_proposal.go`)。
|
||||
* 各平台各写一遍拼接,某一处少个空格或把双引号写成单引号,服务端匹配不上 ——
|
||||
* 而失败是静默的:邮件照常发出,提议凭空消失,模型以为自己提过了。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 服务端能识别的别名字符集。
|
||||
*
|
||||
* 与 `validateSessionAlias` 一致:`. 空白 / @` 会与三维地址解析冲突,
|
||||
* `new` 是寻址保留字。这里**不做规范化**(不把非法字符替换成 `-`)——
|
||||
* 规范化是服务端 `normalizeAlias` 的职责,插件擅自改写会让模型看到的
|
||||
* 「我提议的名字」与实际入库的不一致。
|
||||
*
|
||||
* @param {string} alias
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isProposableAlias(alias) {
|
||||
const a = String(alias ?? '').trim();
|
||||
if (!a) return false;
|
||||
if (a === 'new') return false;
|
||||
// 双引号是标记本身的定界符,含它会截断标记
|
||||
if (/[.\s/@"]/.test(a)) return false;
|
||||
// 服务端 VARCHAR(128),按字节算
|
||||
if (Buffer.byteLength(a, 'utf8') > 128) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把改名提议标记追加到正文末尾。
|
||||
*
|
||||
* 格式必须与服务端正则逐字符对应:
|
||||
* `<!-- agentmail:rename-session alias="x" reason="y" -->`
|
||||
* reason 可选,为空时**整个属性都不写**(写成 `reason=""` 服务端会存一个空理由,
|
||||
* 界面上的提示条就少了那句解释)。
|
||||
*
|
||||
* 别名不合法时**原样返回正文**,不追加标记:与其发一个服务端匹配得上却
|
||||
* 被 `validateSessionAlias` 拒掉的标记,不如当它没提 —— 调用方据此告诉模型。
|
||||
*
|
||||
* @param {string} body 原始正文
|
||||
* @param {string} [alias] 提议的别名
|
||||
* @param {string} [reason] 提议理由,一句话
|
||||
* @returns {{body: string, proposed: boolean}} proposed=false 表示别名不合法,未追加
|
||||
*/
|
||||
export function appendRenameProposal(body, alias, reason) {
|
||||
const text = String(body ?? '');
|
||||
if (!isProposableAlias(alias)) return { body: text, proposed: false };
|
||||
|
||||
const a = String(alias).trim();
|
||||
// 理由里的双引号会截断标记,去掉而不是转义:HTML 注释里没有转义机制
|
||||
const r = String(reason ?? '').replace(/"/g, '').trim();
|
||||
const reasonAttr = r ? ` reason="${r}"` : '';
|
||||
|
||||
return {
|
||||
body: `${text}\n\n<!-- agentmail:rename-session alias="${a}"${reasonAttr} -->`,
|
||||
proposed: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 提议提交后回给模型的那句话。
|
||||
*
|
||||
* **别名取服务端回的 `rename_proposed`,不是本地提议的那个。** 服务端会跑
|
||||
* `normalizeAlias` —— 非法字符换成 `-`、`new` 变 `session-new`、超长按 UTF-8
|
||||
* 边界截断。回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404。
|
||||
*
|
||||
* 必须说明「等人确认」。不说的话模型会以为改名已经生效,接着在后续邮件里
|
||||
* 用新别名当地址发信 —— 而那个别名此刻还不存在,投递会失败。
|
||||
*
|
||||
* @param {string} [serverAlias] 服务端 `/mail/send` 响应里的 `rename_proposed`
|
||||
* @param {string} [requestedAlias] 本地提议的别名,仅用于「未提交」时的说明
|
||||
* @param {boolean} [proposed] appendRenameProposal 的返回值
|
||||
* @returns {string} 空串表示没有需要追加的说明
|
||||
*/
|
||||
export function renameProposalNote(serverAlias, requestedAlias, proposed) {
|
||||
const server = String(serverAlias ?? '').trim();
|
||||
const wanted = String(requestedAlias ?? '').trim();
|
||||
|
||||
// 服务端确认收到了:用它给的最终值
|
||||
if (server) {
|
||||
const changed = wanted && wanted !== server
|
||||
? `(你提的 "${wanted}" 被规范化成了这个)`
|
||||
: '';
|
||||
return `已附上改名提议 "${server}"${changed},等用户在界面上确认后生效 —— ` +
|
||||
`在那之前继续用原别名寻址。`;
|
||||
}
|
||||
|
||||
if (!wanted) return '';
|
||||
|
||||
// 本地就判定不合法,标记没发出去
|
||||
if (!proposed) {
|
||||
return `(改名提议 "${wanted}" 未提交:别名不可为 new,不可含 . 空白 / @ 或双引号。)`;
|
||||
}
|
||||
|
||||
// 标记发出去了但服务端没回 rename_proposed:它那侧的校验也拒了
|
||||
return `(改名提议 "${wanted}" 未被服务端接受,会话别名不变。)`;
|
||||
}
|
||||
136
plugins/opencode-mail-bridge/test/rename-proposal.test.mjs
Normal file
136
plugins/opencode-mail-bridge/test/rename-proposal.test.mjs
Normal file
@ -0,0 +1,136 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
isProposableAlias,
|
||||
appendRenameProposal,
|
||||
renameProposalNote,
|
||||
} from '../lib/rename-proposal.js';
|
||||
|
||||
// 这一组测试钉住的是「插件拼的标记与服务端正则逐字符对应」。
|
||||
// 服务端那条正则在 gateway/internal/handler/rename_proposal.go:
|
||||
// <!--\s*agentmail:rename-session\s+alias="([^"]*)"(?:\s+reason="([^"]*)")?\s*-->
|
||||
// 拼错不会报错 —— 邮件照常发出,提议凭空消失。
|
||||
|
||||
/** 服务端正则的等价实现,用来验证我们拼出来的标记真的能被摘出来。 */
|
||||
const SERVER_RE =
|
||||
/<!--\s*agentmail:rename-session\s+alias="([^"]*)"(?:\s+reason="([^"]*)")?\s*-->/s;
|
||||
|
||||
test('isProposableAlias: 合法别名', () => {
|
||||
assert.equal(isProposableAlias('fix-login-leak'), true);
|
||||
assert.equal(isProposableAlias('修复登录态泄漏'), true, '中文别名合法');
|
||||
assert.equal(isProposableAlias('v2_migration'), true);
|
||||
});
|
||||
|
||||
test('isProposableAlias: new 是寻址保留字', () => {
|
||||
// `.new` 是「强制新建会话」的动作,别名叫 new 会让地址无从解释
|
||||
assert.equal(isProposableAlias('new'), false);
|
||||
});
|
||||
|
||||
test('isProposableAlias: 拒绝与三维地址冲突的字符', () => {
|
||||
// 这四个字符都会让 name@path.session 的切分产生歧义
|
||||
assert.equal(isProposableAlias('a.b'), false, '. 是 session 位分隔符');
|
||||
assert.equal(isProposableAlias('a/b'), false, '/ 出现在 path 位');
|
||||
assert.equal(isProposableAlias('a@b'), false, '@ 是 name/path 分隔符');
|
||||
assert.equal(isProposableAlias('a b'), false, '空白');
|
||||
assert.equal(isProposableAlias('a\tb'), false, '制表符也算空白');
|
||||
});
|
||||
|
||||
test('isProposableAlias: 拒绝双引号', () => {
|
||||
// 双引号是标记自身的定界符,含它会把标记截断成非法形式
|
||||
assert.equal(isProposableAlias('say"hi'), false);
|
||||
});
|
||||
|
||||
test('isProposableAlias: 空与空白视为没提', () => {
|
||||
assert.equal(isProposableAlias(''), false);
|
||||
assert.equal(isProposableAlias(' '), false);
|
||||
assert.equal(isProposableAlias(undefined), false);
|
||||
assert.equal(isProposableAlias(null), false);
|
||||
});
|
||||
|
||||
test('isProposableAlias: 超过 128 字节按字节算', () => {
|
||||
// 服务端是 VARCHAR(128)。中文一个字 3 字节,43 字 = 129 字节
|
||||
assert.equal(isProposableAlias('a'.repeat(128)), true);
|
||||
assert.equal(isProposableAlias('a'.repeat(129)), false);
|
||||
assert.equal(isProposableAlias('汉'.repeat(42)), true, '126 字节');
|
||||
assert.equal(isProposableAlias('汉'.repeat(43)), false, '129 字节');
|
||||
});
|
||||
|
||||
test('标记能被服务端正则摘出来', () => {
|
||||
const { body, proposed } = appendRenameProposal('已定位到问题。', 'fix-login-leak', '登录态泄漏');
|
||||
assert.equal(proposed, true);
|
||||
const m = SERVER_RE.exec(body);
|
||||
assert.ok(m, '服务端正则必须匹配得上');
|
||||
assert.equal(m[1], 'fix-login-leak');
|
||||
assert.equal(m[2], '登录态泄漏');
|
||||
});
|
||||
|
||||
test('没有理由时整个 reason 属性都不写', () => {
|
||||
// 写成 reason="" 会让服务端存一个空理由,界面提示条就少了那句解释
|
||||
const { body } = appendRenameProposal('正文', 'fix-leak');
|
||||
assert.doesNotMatch(body, /reason=/);
|
||||
const m = SERVER_RE.exec(body);
|
||||
assert.equal(m[1], 'fix-leak');
|
||||
assert.equal(m[2], undefined);
|
||||
});
|
||||
|
||||
test('理由里的双引号被去掉而不是转义', () => {
|
||||
// HTML 注释里没有转义机制,留着会截断标记
|
||||
const { body } = appendRenameProposal('正文', 'fix-leak', '他说"这是泄漏"');
|
||||
const m = SERVER_RE.exec(body);
|
||||
assert.ok(m);
|
||||
assert.equal(m[2], '他说这是泄漏');
|
||||
});
|
||||
|
||||
test('原正文完整保留在标记之前', () => {
|
||||
const original = '第一行\n\n第二行';
|
||||
const { body } = appendRenameProposal(original, 'fix-leak');
|
||||
assert.ok(body.startsWith(original), '正文不得被改写');
|
||||
});
|
||||
|
||||
test('别名不合法时原样返回,不追加标记', () => {
|
||||
const { body, proposed } = appendRenameProposal('正文', 'a.b');
|
||||
assert.equal(proposed, false);
|
||||
assert.equal(body, '正文');
|
||||
assert.doesNotMatch(body, /agentmail:rename-session/);
|
||||
});
|
||||
|
||||
test('不给别名时正文完全不变', () => {
|
||||
const { body, proposed } = appendRenameProposal('正文', '');
|
||||
assert.equal(proposed, false);
|
||||
assert.equal(body, '正文');
|
||||
});
|
||||
|
||||
test('renameProposalNote: 用服务端回的别名,不是本地提议的', () => {
|
||||
// 服务端跑 normalizeAlias:非法字符换 -、new 变 session-new、超长截断。
|
||||
// 回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404。
|
||||
const note = renameProposalNote('fix-login-leak', 'fix.login.leak', true);
|
||||
assert.match(note, /fix-login-leak/);
|
||||
assert.match(note, /规范化/, '要告诉模型名字被改写过');
|
||||
assert.match(note, /确认/);
|
||||
assert.match(note, /原别名/, '要明确说在那之前用哪个');
|
||||
});
|
||||
|
||||
test('renameProposalNote: 服务端别名与提议一致时不提规范化', () => {
|
||||
const note = renameProposalNote('fix-leak', 'fix-leak', true);
|
||||
assert.match(note, /fix-leak/);
|
||||
assert.doesNotMatch(note, /规范化/);
|
||||
});
|
||||
|
||||
test('renameProposalNote: 本地判非法时说清为什么', () => {
|
||||
const note = renameProposalNote('', 'a.b', false);
|
||||
assert.match(note, /未提交/);
|
||||
assert.match(note, /a\.b/);
|
||||
});
|
||||
|
||||
test('renameProposalNote: 标记发出但服务端没接受', () => {
|
||||
// 本地校验比服务端宽的情况(例如服务端加了新约束)——
|
||||
// 不能沉默,否则模型以为提议成功了
|
||||
const note = renameProposalNote('', 'somealias', true);
|
||||
assert.match(note, /未被服务端接受/);
|
||||
assert.match(note, /somealias/);
|
||||
});
|
||||
|
||||
test('renameProposalNote: 没提议时不产生噪音', () => {
|
||||
assert.equal(renameProposalNote('', '', false), '');
|
||||
assert.equal(renameProposalNote(undefined, undefined, false), '');
|
||||
});
|
||||
109
plugins/pi-mail-bridge/lib/rename-proposal.js
Normal file
109
plugins/pi-mail-bridge/lib/rename-proposal.js
Normal file
@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 会话改名提议 —— 所有平台插件共用。
|
||||
*
|
||||
* # 这是什么
|
||||
*
|
||||
* 模型干完活后可能觉得当前别名不贴切:会话建立时叫 `witty-planet`(平台随机 slug)
|
||||
* 或 `排查登录问题`(人写的邮件主题),摸清问题后它知道这其实是
|
||||
* `fix-session-cookie-leak`。改名提议就是让它把这个判断说出来。
|
||||
*
|
||||
* # 为什么是「提议」而不是直接改
|
||||
*
|
||||
* 别名是**人**的寻址入口 —— `name@path.<别名>` 里那一段。Agent 干到一半自己改掉,
|
||||
* 人上一秒记住的地址下一秒就 404(`session` 位三态语义要求指向不存在的会话直接报
|
||||
* 「无法送达」,不会静默新建)。所以提议入库、由人在界面上点「接受」才真正生效。
|
||||
*
|
||||
* 这与平台命名自动同步(`POST /sessions/{id}/sync`)互补,两者不冲突:
|
||||
*
|
||||
* | | 谁发起 | 何时 | 是否打扰人 |
|
||||
* |---|---|---|---|
|
||||
* | 自动同步 | 平台的命名机制 | 每轮结束 | 不,后台静默生效 |
|
||||
* | 改名提议 | 模型的主动判断 | 它认为有必要时 | 是,界面上出提示条 |
|
||||
*
|
||||
* # 为什么载体是 HTML 注释
|
||||
*
|
||||
* `/mail/send` 没有 `propose_alias` 字段 —— 提议**搭在正文里**发出去,
|
||||
* 服务端用正则摘出来再把标记从入库正文中剥掉。选 HTML 注释的三个理由:
|
||||
*
|
||||
* - react-markdown 默认不解析 raw HTML,万一服务端没剥掉,它在页面上也只是
|
||||
* 一行不显眼的转义文本,不会破版
|
||||
* - 纯文本邮件客户端里是一行不碍事的注释,不像自造标记那样显眼
|
||||
* - 不与 Markdown 语法冲突,格式化工具不会改写它
|
||||
*
|
||||
* # 为什么必须共用
|
||||
*
|
||||
* 标记格式是**服务端正则的镜像**(`gateway/internal/handler/rename_proposal.go`)。
|
||||
* 各平台各写一遍拼接,某一处少个空格或把双引号写成单引号,服务端匹配不上 ——
|
||||
* 而失败是静默的:邮件照常发出,提议凭空消失,模型以为自己提过了。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 服务端能识别的别名字符集。
|
||||
*
|
||||
* 与 `validateSessionAlias` 一致:`. 空白 / @` 会与三维地址解析冲突,
|
||||
* `new` 是寻址保留字。这里**不做规范化**(不把非法字符替换成 `-`)——
|
||||
* 规范化是服务端 `normalizeAlias` 的职责,插件擅自改写会让模型看到的
|
||||
* 「我提议的名字」与实际入库的不一致。
|
||||
*
|
||||
* @param {string} alias
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isProposableAlias(alias) {
|
||||
const a = String(alias ?? '').trim();
|
||||
if (!a) return false;
|
||||
if (a === 'new') return false;
|
||||
// 双引号是标记本身的定界符,含它会截断标记
|
||||
if (/[.\s/@"]/.test(a)) return false;
|
||||
// 服务端 VARCHAR(128),按字节算
|
||||
if (Buffer.byteLength(a, 'utf8') > 128) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把改名提议标记追加到正文末尾。
|
||||
*
|
||||
* 格式必须与服务端正则逐字符对应:
|
||||
* `<!-- agentmail:rename-session alias="x" reason="y" -->`
|
||||
* reason 可选,为空时**整个属性都不写**(写成 `reason=""` 服务端会存一个空理由,
|
||||
* 界面上的提示条就少了那句解释)。
|
||||
*
|
||||
* 别名不合法时**原样返回正文**,不追加标记:与其发一个服务端匹配得上却
|
||||
* 被 `validateSessionAlias` 拒掉的标记,不如当它没提 —— 调用方据此告诉模型。
|
||||
*
|
||||
* @param {string} body 原始正文
|
||||
* @param {string} [alias] 提议的别名
|
||||
* @param {string} [reason] 提议理由,一句话
|
||||
* @returns {{body: string, proposed: boolean}} proposed=false 表示别名不合法,未追加
|
||||
*/
|
||||
export function appendRenameProposal(body, alias, reason) {
|
||||
const text = String(body ?? '');
|
||||
if (!isProposableAlias(alias)) return { body: text, proposed: false };
|
||||
|
||||
const a = String(alias).trim();
|
||||
// 理由里的双引号会截断标记,去掉而不是转义:HTML 注释里没有转义机制
|
||||
const r = String(reason ?? '').replace(/"/g, '').trim();
|
||||
const reasonAttr = r ? ` reason="${r}"` : '';
|
||||
|
||||
return {
|
||||
body: `${text}\n\n<!-- agentmail:rename-session alias="${a}"${reasonAttr} -->`,
|
||||
proposed: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 提议提交后回给模型的那句话。
|
||||
*
|
||||
* 必须说明「等人确认」。不说的话模型会以为改名已经生效,接着在后续邮件里
|
||||
* 用新别名当地址发信 —— 而那个别名此刻还不存在,投递会失败。
|
||||
*
|
||||
* @param {string} alias
|
||||
* @param {boolean} proposed appendRenameProposal 的返回值
|
||||
* @returns {string} 空串表示没有需要追加的说明
|
||||
*/
|
||||
export function renameProposalNote(alias, proposed) {
|
||||
if (!alias) return '';
|
||||
if (!proposed) {
|
||||
return `(改名提议 "${alias}" 未提交:别名不可为 new,不可含 . 空白 / @ 或双引号。)`;
|
||||
}
|
||||
return `已附上改名提议 "${alias}",等用户在界面上确认后生效 —— 在那之前继续用原别名寻址。`;
|
||||
}
|
||||
@ -11,9 +11,24 @@ import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const CONFIG_DIR = process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), '.agentmail');
|
||||
const KEY_FILE = join(CONFIG_DIR, 'agent.key');
|
||||
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 {
|
||||
@ -228,6 +243,28 @@ export class GatewayClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 换 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.lastEventID = '';
|
||||
this.baseURL = nextURL;
|
||||
this.agentKey = nextKey;
|
||||
return true;
|
||||
}
|
||||
|
||||
stopSSE() {
|
||||
this.sseAbort?.abort();
|
||||
this.sseAbort = null;
|
||||
|
||||
@ -10,8 +10,8 @@
|
||||
* 「工具能拿到当前会话 id」,自动转发去重(B-5.3)靠它把发信记到正确的会话上。
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { basename } from 'node:path';
|
||||
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
||||
import { basename, dirname } from 'node:path';
|
||||
import {
|
||||
renderInbox,
|
||||
idsToMarkRead,
|
||||
@ -28,6 +28,8 @@ import {
|
||||
renderThread,
|
||||
} from '../lib/discovery.js';
|
||||
import { noteExplicitSend } from '../lib/relay-dedup.js';
|
||||
import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js';
|
||||
import { saveLocalKey, generateLocalKey, saveConfig, KEY_FILE } from './gateway.mjs';
|
||||
|
||||
const text = (s) => ({ content: [{ type: 'text', text: s }] });
|
||||
|
||||
@ -37,8 +39,10 @@ const text = (s) => ({ content: [{ type: 'text', text: s }] });
|
||||
* @param {(msg: string) => void} deps.log
|
||||
* @param {string} [deps.agentName] 自己的 Agent 名。收件箱渲染靠它判定
|
||||
* 「我是收件人还是抄送方」并给出可投递地址。
|
||||
* @param {() => void} [deps.onReconnect] connect_to_server 换了坐标后调用,
|
||||
* 由入口重连 SSE。不给则只改客户端字段(下次重连时生效)。
|
||||
*/
|
||||
export function createMailTools({ client, log, agentName = '' }) {
|
||||
export function createMailTools({ client, log, agentName = '', onReconnect }) {
|
||||
const sendMail = {
|
||||
name: 'send_mail',
|
||||
label: 'SendMail',
|
||||
@ -59,15 +63,29 @@ export function createMailTools({ client, log, agentName = '' }) {
|
||||
items: { type: 'string' },
|
||||
description: '附件 ID 列表(先用 upload_attachment 取得)',
|
||||
},
|
||||
propose_alias: {
|
||||
type: 'string',
|
||||
description:
|
||||
'建议把当前会话改名成这个别名(例如摸清问题后从「排查登录问题」改成 ' +
|
||||
'fix-session-cookie-leak)。这只是建议:别名是人的寻址入口,实际改名由用户' +
|
||||
'在界面上确认。不可含 . / @ 空白,不可为 new',
|
||||
},
|
||||
propose_reason: { type: 'string', description: '改名理由,一句话,展示给用户看' },
|
||||
},
|
||||
required: ['to', 'subject', 'body'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id, params, _signal, _onUpdate, ctx) {
|
||||
// 改名提议以 HTML 注释形式附在正文末尾,由网关解析后剥离。
|
||||
// 拼接收在 lib/rename-proposal.js(三平台共用):标记格式是服务端正则的
|
||||
// 镜像,各写一遍的话少个空格就静默失效 —— 邮件照常发出,提议凭空消失。
|
||||
const { body, proposed } = appendRenameProposal(
|
||||
params.body, params.propose_alias, params.propose_reason);
|
||||
|
||||
const result = await client.post('/mail/send', {
|
||||
to: params.to,
|
||||
subject: params.subject,
|
||||
body: params.body,
|
||||
body,
|
||||
cc: params.cc || '',
|
||||
reply_to: params.reply_to || '',
|
||||
session_alias: params.session_alias || '',
|
||||
@ -81,7 +99,11 @@ export function createMailTools({ client, log, agentName = '' }) {
|
||||
const budget = typeof result.budget_remaining === 'number'
|
||||
? ` 本任务剩余 ${result.budget_remaining}/${result.budget_max} 个来回。`
|
||||
: '';
|
||||
return text(`邮件已发送(ID: ${result.mail_id})${budget}`);
|
||||
// 别名取服务端回的 rename_proposed(它跑过 normalizeAlias),
|
||||
// 回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404
|
||||
const note = renameProposalNote(result.rename_proposed, params.propose_alias, proposed);
|
||||
return text(
|
||||
`邮件已发送(ID: ${result.mail_id})${budget}` + (note ? `\n${note}` : ''));
|
||||
},
|
||||
};
|
||||
|
||||
@ -159,20 +181,38 @@ export function createMailTools({ client, log, agentName = '' }) {
|
||||
const uploadAttachment = {
|
||||
name: 'upload_attachment',
|
||||
label: 'UploadAttachment',
|
||||
description: '上传本地文件作为邮件附件,返回 attachment_id。',
|
||||
description:
|
||||
'上传本地文件作为邮件附件,返回 attachment_id。' +
|
||||
'拿到 id 后必须在 send_mail 的 attachment_ids 里带上,附件才会随邮件发出。' +
|
||||
'未随邮件发出的附件 24 小时后自动清理。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: { type: 'string', description: '本地文件的绝对路径' },
|
||||
file_path: { type: 'string', description: '要上传的本地文件绝对路径' },
|
||||
filename: { type: 'string', description: '自定义展示文件名,默认取路径的最后一段' },
|
||||
},
|
||||
required: ['file_path'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id, params) {
|
||||
// 先 stat 再读:目录和不存在的路径都要给出能行动的错误。
|
||||
// 直接 readFile 的话,目录会抛 EISDIR —— 模型看到那个 errno
|
||||
// 只会重试同一个路径,而不是去改参数。
|
||||
let st;
|
||||
try {
|
||||
st = await stat(params.file_path);
|
||||
} catch {
|
||||
return text(`文件不存在或不可读: ${params.file_path}`);
|
||||
}
|
||||
if (!st.isFile()) return text(`不是普通文件: ${params.file_path}`);
|
||||
|
||||
// 附件上限 25MB,一次性读入内存可接受。上限放宽的话这里要改成流式 multipart。
|
||||
const buf = await readFile(params.file_path);
|
||||
const a = await client.uploadFile(buf, basename(params.file_path) || 'file');
|
||||
const name = params.filename || basename(params.file_path) || 'file';
|
||||
const a = await client.uploadFile(buf, name);
|
||||
return text(
|
||||
`已上传 ${a.filename}(${formatSize(a.size_bytes)})。attachment_id: ${a.attachment_id}`,
|
||||
`已上传 ${a.filename}(${formatSize(a.size_bytes)})。attachment_id: ${a.attachment_id}\n` +
|
||||
`在 send_mail 的 attachment_ids 里带上这个 id 才会随邮件发出。`,
|
||||
);
|
||||
},
|
||||
};
|
||||
@ -180,18 +220,21 @@ export function createMailTools({ client, log, agentName = '' }) {
|
||||
const downloadAttachment = {
|
||||
name: 'download_attachment',
|
||||
label: 'DownloadAttachment',
|
||||
description: '下载邮件附件到本地文件。',
|
||||
description: '下载邮件附件到本地文件。attachment_id 从 read_inbox 的附件清单里取。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
attachment_id: { type: 'string', description: '附件 ID(read_inbox 的清单里给出)' },
|
||||
save_path: { type: 'string', description: '保存路径' },
|
||||
save_path: { type: 'string', description: '保存到的本地绝对路径' },
|
||||
},
|
||||
required: ['attachment_id', 'save_path'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const buf = await client.downloadFile(params.attachment_id);
|
||||
// 父目录不存在时先建:模型经常写 ./downloads/x.pdf 这类还不存在的路径,
|
||||
// 不建的话 writeFile 抛 ENOENT,而那个错误看起来像「附件不存在」。
|
||||
await mkdir(dirname(params.save_path), { recursive: true });
|
||||
await writeFile(params.save_path, buf);
|
||||
return text(`已保存到 ${params.save_path}(${formatSize(buf.length)})`);
|
||||
},
|
||||
@ -343,6 +386,73 @@ export function createMailTools({ client, log, agentName = '' }) {
|
||||
},
|
||||
};
|
||||
|
||||
// connect_to_server —— 连接自愈。
|
||||
//
|
||||
// 之前只有 opencode 侧有。后果是:Gateway 换了地址、或密钥需要重新登记时,
|
||||
// opencode 里的模型能自己修好,其他平台只能干等环境变量被人改 ——
|
||||
// 同一类能力在不同平台上时有时无,等于让人记住哪个平台能自己修。
|
||||
//
|
||||
// 失败时**把需要登记的密钥全文打出来**:密钥未登记是最常见的失败,
|
||||
// 不给值的话要多走一轮「密钥无效 → 去哪拿 → 让管理员登记」。
|
||||
const connectToServer = {
|
||||
name: 'connect_to_server',
|
||||
label: 'ConnectToServer',
|
||||
description:
|
||||
'连接到 AgentMail Gateway:登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。' +
|
||||
'密钥若未在后台登记过,此处会返回需要登记的密钥全文。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
gateway_url: { type: 'string', description: 'Gateway 地址;省略则用当前配置' },
|
||||
key_token: { type: 'string', description: '管理员签发的 Agent 密钥;省略则用本地密钥(不存在时自动生成)' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id, params) {
|
||||
let key = client.agentKey;
|
||||
if (params.key_token) {
|
||||
key = String(params.key_token).trim();
|
||||
// 管理员给的密钥落盘,重启后仍然可用
|
||||
saveLocalKey(key);
|
||||
} else if (!key) {
|
||||
key = generateLocalKey(log);
|
||||
}
|
||||
|
||||
const url = String(params.gateway_url || client.baseURL).replace(/\/+$/, '');
|
||||
|
||||
const res = await fetch(`${url}/api/v1/agent/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
||||
body: JSON.stringify({ name: client.agentName, workspaces: [], platform: 'pi' }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
return text([
|
||||
`连接失败(HTTP ${res.status}):${data?.error || '未知错误'}`,
|
||||
``,
|
||||
`若提示密钥无效,请让管理员在 AgentMail 后台「Agent 密钥」中登记:`,
|
||||
key,
|
||||
``,
|
||||
`密钥文件:${KEY_FILE}`,
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
// 成功。桥是守护进程,不能靠重启来应用新坐标 —— 模型调这个工具时
|
||||
// 期望调完就能收信,所以要当场改客户端并重连 SSE。
|
||||
// reconfigure 顺带清掉 lastEventID:那是旧 Gateway 缓冲里的序号。
|
||||
const changed = client.reconfigure({ url, agentKey: key });
|
||||
saveConfig({ gateway_url: url, agent_name: client.agentName, registered_at: new Date().toISOString() });
|
||||
if (changed && onReconnect) {
|
||||
onReconnect();
|
||||
log(`connect_to_server 换了坐标,SSE 已重连到 ${url}`);
|
||||
}
|
||||
return text(
|
||||
`已连接 ${url},注册为 ${data?.agent_name || client.agentName}。` +
|
||||
(changed ? '事件流已切到新地址。' : ''));
|
||||
},
|
||||
};
|
||||
|
||||
// 故意**没有** request_permission(N-1 / T-7):
|
||||
// 权限询问由 tool_call 钩子接管 —— 模型可能忘了调,也可能在不需要时乱调,
|
||||
// 而真正被 pi 拦下的那一次才是事实。
|
||||
@ -351,5 +461,7 @@ export function createMailTools({ client, log, agentName = '' }) {
|
||||
uploadAttachment, downloadAttachment,
|
||||
// 寻址发现:让模型选地址而不是拼地址
|
||||
suggestAddress, listContacts, sessionParticipants, readThread,
|
||||
// 连接自愈
|
||||
connectToServer,
|
||||
];
|
||||
}
|
||||
|
||||
119
plugins/pi-mail-bridge/test/rename-proposal.test.mjs
Normal file
119
plugins/pi-mail-bridge/test/rename-proposal.test.mjs
Normal file
@ -0,0 +1,119 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
isProposableAlias,
|
||||
appendRenameProposal,
|
||||
renameProposalNote,
|
||||
} from '../lib/rename-proposal.js';
|
||||
|
||||
// 这一组测试钉住的是「插件拼的标记与服务端正则逐字符对应」。
|
||||
// 服务端那条正则在 gateway/internal/handler/rename_proposal.go:
|
||||
// <!--\s*agentmail:rename-session\s+alias="([^"]*)"(?:\s+reason="([^"]*)")?\s*-->
|
||||
// 拼错不会报错 —— 邮件照常发出,提议凭空消失。
|
||||
|
||||
/** 服务端正则的等价实现,用来验证我们拼出来的标记真的能被摘出来。 */
|
||||
const SERVER_RE =
|
||||
/<!--\s*agentmail:rename-session\s+alias="([^"]*)"(?:\s+reason="([^"]*)")?\s*-->/s;
|
||||
|
||||
test('isProposableAlias: 合法别名', () => {
|
||||
assert.equal(isProposableAlias('fix-login-leak'), true);
|
||||
assert.equal(isProposableAlias('修复登录态泄漏'), true, '中文别名合法');
|
||||
assert.equal(isProposableAlias('v2_migration'), true);
|
||||
});
|
||||
|
||||
test('isProposableAlias: new 是寻址保留字', () => {
|
||||
// `.new` 是「强制新建会话」的动作,别名叫 new 会让地址无从解释
|
||||
assert.equal(isProposableAlias('new'), false);
|
||||
});
|
||||
|
||||
test('isProposableAlias: 拒绝与三维地址冲突的字符', () => {
|
||||
// 这四个字符都会让 name@path.session 的切分产生歧义
|
||||
assert.equal(isProposableAlias('a.b'), false, '. 是 session 位分隔符');
|
||||
assert.equal(isProposableAlias('a/b'), false, '/ 出现在 path 位');
|
||||
assert.equal(isProposableAlias('a@b'), false, '@ 是 name/path 分隔符');
|
||||
assert.equal(isProposableAlias('a b'), false, '空白');
|
||||
assert.equal(isProposableAlias('a\tb'), false, '制表符也算空白');
|
||||
});
|
||||
|
||||
test('isProposableAlias: 拒绝双引号', () => {
|
||||
// 双引号是标记自身的定界符,含它会把标记截断成非法形式
|
||||
assert.equal(isProposableAlias('say"hi'), false);
|
||||
});
|
||||
|
||||
test('isProposableAlias: 空与空白视为没提', () => {
|
||||
assert.equal(isProposableAlias(''), false);
|
||||
assert.equal(isProposableAlias(' '), false);
|
||||
assert.equal(isProposableAlias(undefined), false);
|
||||
assert.equal(isProposableAlias(null), false);
|
||||
});
|
||||
|
||||
test('isProposableAlias: 超过 128 字节按字节算', () => {
|
||||
// 服务端是 VARCHAR(128)。中文一个字 3 字节,43 字 = 129 字节
|
||||
assert.equal(isProposableAlias('a'.repeat(128)), true);
|
||||
assert.equal(isProposableAlias('a'.repeat(129)), false);
|
||||
assert.equal(isProposableAlias('汉'.repeat(42)), true, '126 字节');
|
||||
assert.equal(isProposableAlias('汉'.repeat(43)), false, '129 字节');
|
||||
});
|
||||
|
||||
test('标记能被服务端正则摘出来', () => {
|
||||
const { body, proposed } = appendRenameProposal('已定位到问题。', 'fix-login-leak', '登录态泄漏');
|
||||
assert.equal(proposed, true);
|
||||
const m = SERVER_RE.exec(body);
|
||||
assert.ok(m, '服务端正则必须匹配得上');
|
||||
assert.equal(m[1], 'fix-login-leak');
|
||||
assert.equal(m[2], '登录态泄漏');
|
||||
});
|
||||
|
||||
test('没有理由时整个 reason 属性都不写', () => {
|
||||
// 写成 reason="" 会让服务端存一个空理由,界面提示条就少了那句解释
|
||||
const { body } = appendRenameProposal('正文', 'fix-leak');
|
||||
assert.doesNotMatch(body, /reason=/);
|
||||
const m = SERVER_RE.exec(body);
|
||||
assert.equal(m[1], 'fix-leak');
|
||||
assert.equal(m[2], undefined);
|
||||
});
|
||||
|
||||
test('理由里的双引号被去掉而不是转义', () => {
|
||||
// HTML 注释里没有转义机制,留着会截断标记
|
||||
const { body } = appendRenameProposal('正文', 'fix-leak', '他说"这是泄漏"');
|
||||
const m = SERVER_RE.exec(body);
|
||||
assert.ok(m);
|
||||
assert.equal(m[2], '他说这是泄漏');
|
||||
});
|
||||
|
||||
test('原正文完整保留在标记之前', () => {
|
||||
const original = '第一行\n\n第二行';
|
||||
const { body } = appendRenameProposal(original, 'fix-leak');
|
||||
assert.ok(body.startsWith(original), '正文不得被改写');
|
||||
});
|
||||
|
||||
test('别名不合法时原样返回,不追加标记', () => {
|
||||
const { body, proposed } = appendRenameProposal('正文', 'a.b');
|
||||
assert.equal(proposed, false);
|
||||
assert.equal(body, '正文');
|
||||
assert.doesNotMatch(body, /agentmail:rename-session/);
|
||||
});
|
||||
|
||||
test('不给别名时正文完全不变', () => {
|
||||
const { body, proposed } = appendRenameProposal('正文', '');
|
||||
assert.equal(proposed, false);
|
||||
assert.equal(body, '正文');
|
||||
});
|
||||
|
||||
test('renameProposalNote: 成功时必须说明等人确认', () => {
|
||||
// 不说的话模型会以为改名已生效,接着用新别名当地址发信 —— 那个别名还不存在
|
||||
const note = renameProposalNote('fix-leak', true);
|
||||
assert.match(note, /fix-leak/);
|
||||
assert.match(note, /确认/);
|
||||
assert.match(note, /原别名/, '要明确说在那之前用哪个');
|
||||
});
|
||||
|
||||
test('renameProposalNote: 失败时说清为什么', () => {
|
||||
const note = renameProposalNote('a.b', false);
|
||||
assert.match(note, /未提交/);
|
||||
assert.match(note, /a\.b/);
|
||||
});
|
||||
|
||||
test('renameProposalNote: 没提议时不产生噪音', () => {
|
||||
assert.equal(renameProposalNote('', false), '');
|
||||
});
|
||||
Reference in New Issue
Block a user