问题(⑧):守护进程用 noExtensions:true 起会话,它的邮件工具只给模型在邮件
会话里用;人在 TUI 里敲的 pi 拿不到。结果是平台的建设者自己收不到邮件 ——
一个「邮件驱动」的平台,维护者只能绕到 curl + 密钥直连 Gateway 才能看收件箱。
新增 plugins/pi-mail-bridge/extension/index.ts:把同一套工具(createMailTools)
注册到交互式会话。两者是同一条 AgentMail 身份(agent pi)的两个入口,与 DSH 的
「TUI + 邮箱是同一个 Agent」一致。
密钥解析顺序(交互式 pi 的环境里没有 AGENTMAIL_*):
1. 进程环境
2. AGENTMAIL_ENV_FILE(默认 /etc/agentmail/pi.env)—— 与守护进程同一把密钥,
因此身份一致
3. AGENTMAIL_CONFIG_DIR/agent.key 或 ~/.agentmail/agent.key
(兼容 key 与 key_token 两种字段名;实测本机文件用的是 key_token,
只认 key 会静默读不到)
拿不到密钥时不注册任何工具并明确告知 —— 挂一组永远 401 的工具比没有更糟。
不注册 connect_to_server:它会重写 Gateway 坐标并重新登记密钥,而交互式会话与
守护进程共用同一身份,一次 TUI 对话不该改到守护进程的配置。
为什么不会重复注册(读 SDK 实现确认,并用探针实测):
resource-loader.js 里 noExtensions 为真时只用 cliEnabledExtensions,
settings.json 的 extensions 数组被排除 —— 即 noExtensions:true 只加载
命令行 -e 传入的扩展。
探针:noExtensions=true → 扩展数=0;false → 16 个且含 pi-mail-bridge。
deploy/install.sh 增加幂等的扩展注册步骤(写入 settings.json 的 extensions)。
验证:headless pi 实际调用 read_inbox 返回真实邮件主题;工具清单含
send_mail/read_inbox/read_mail/forward_mail/upload_attachment/download_attachment/
suggest_address/list_contacts/session_participants/read_thread(10 个),
connect_to_server 按设计排除。
188 lines
7.5 KiB
TypeScript
188 lines
7.5 KiB
TypeScript
/**
|
||
* pi 交互式会话的邮件工具扩展。
|
||
*
|
||
* # 与 src/index.mjs(常驻守护进程)的分工
|
||
*
|
||
* - 守护进程负责**收信**:订阅 SSE、fork worker、跑模型、自动回信
|
||
* - 本扩展只给**交互式 TUI 会话**装上同一套邮件工具
|
||
*
|
||
* 两者是同一条 AgentMail 身份(agent `pi`)的两个入口,与 DSH 的
|
||
* 「TUI + 邮箱是同一个 Agent」完全一致。人可以在 TUI 里直接收发邮件,
|
||
* 也可以在邮箱里给 pi 发信 —— 两边看到的是同一条会话流。
|
||
*
|
||
* # 为什么必须单独做这个扩展
|
||
*
|
||
* 守护进程用 `noExtensions: true` 起会话(见 src/session-pool.mjs),
|
||
* 所以它的邮件工具**不会**出现在人的交互式 pi 里。而平台的建设者与维护者
|
||
* 恰恰是在交互式 pi 里工作的:没有 send_mail / read_inbox,他既看不到
|
||
* 自己刚发出的那封信,也无法回信,只能绕到 curl + 密钥直连 Gateway ——
|
||
* 一个「邮件驱动」的平台,维护者自己收不到邮件。
|
||
*
|
||
* # 密钥从哪来
|
||
*
|
||
* 交互式 pi 的进程环境里通常**没有** AGENTMAIL_* 变量(守护进程的
|
||
* EnvironmentFile 只注入给它自己)。因此按以下顺序解析:
|
||
*
|
||
* 1. 进程环境(`AGENTMAIL_AGENT_KEY` / `AGENTMAIL_GATEWAY_URL` / `AGENTMAIL_AGENT_NAME`)
|
||
* 2. `AGENTMAIL_ENV_FILE`(默认 `/etc/agentmail/pi.env`)—— 部署时的权威来源,
|
||
* 与守护进程用的是同一把密钥,因此身份一致
|
||
* 3. `$AGENTMAIL_CONFIG_DIR/agent.key` 或 `~/.agentmail/agent.key`
|
||
* (插件的历史约定;接受 `key` 与 `key_token` 两种字段名)
|
||
*
|
||
* 找不到密钥时**不注册任何工具**并明确告知 —— 挂上一组永远 401 的工具比没有更糟:
|
||
* 模型会以为自己能发信,人却只看到一串认证失败。
|
||
*
|
||
* # 为什么不注册 connect_to_server
|
||
*
|
||
* 那个工具会重写 Gateway 坐标并重新登记密钥。在交互式会话里调用它会**动到守护
|
||
* 进程的配置**(两者共用同一把密钥与同一个 Agent 名),而守护进程才是真正长期
|
||
* 持有 SSE 长连的一方。坐标变更属于部署动作,不该由一次 TUI 对话触发。
|
||
*/
|
||
|
||
import { existsSync, readFileSync } from 'node:fs';
|
||
import { homedir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
||
|
||
const DEFAULT_ENV_FILE = '/etc/agentmail/pi.env';
|
||
const DEFAULT_GATEWAY = 'http://127.0.0.1:8180';
|
||
|
||
/** 守护进程专用的生命周期工具:交互式会话里不注册(理由见文件头注释)。 */
|
||
const EXCLUDED_TOOLS = new Set(['connect_to_server']);
|
||
|
||
/**
|
||
* registerTool 期望的完整定义形状。
|
||
*
|
||
* 用 SDK 自己的类型取,而不是复制一份:SDK 升级时形状变化会在这里变成编译错误,
|
||
* 而不是运行时的静默错配。
|
||
*/
|
||
type MailTool = Parameters<ExtensionAPI['registerTool']>[0];
|
||
|
||
/** 极简 .env 解析:只认 `KEY=value`,忽略注释与空行,不处理引号与转义。 */
|
||
function parseEnvFile(path: string): Record<string, string> {
|
||
const out: Record<string, string> = {};
|
||
if (!existsSync(path)) return out;
|
||
let raw: string;
|
||
try {
|
||
raw = readFileSync(path, 'utf8');
|
||
} catch {
|
||
return out;
|
||
}
|
||
for (const line of raw.split('\n')) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||
const eq = trimmed.indexOf('=');
|
||
if (eq <= 0) continue;
|
||
const key = trimmed.slice(0, eq).trim();
|
||
// 值里可能有 `=`(例如 base64),只切第一个
|
||
out[key] = trimmed.slice(eq + 1).trim();
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 读插件约定的本地密钥文件,兼容 `key` 与 `key_token` 两种字段名。 */
|
||
function readLocalKey(configDir?: string): string {
|
||
const candidates = [
|
||
join(configDir || join(homedir(), '.agentmail'), 'agent.key'),
|
||
join(homedir(), '.agentmail', 'agent.key'),
|
||
];
|
||
for (const path of candidates) {
|
||
try {
|
||
if (!existsSync(path)) continue;
|
||
const raw = JSON.parse(readFileSync(path, 'utf8')) as
|
||
{ key?: unknown; key_token?: unknown };
|
||
// 历史文件用的是 key_token(见部署实况),只认 `key` 会静默读不到。
|
||
const key = raw?.key ?? raw?.key_token;
|
||
if (typeof key === 'string' && key.trim()) return key.trim();
|
||
} catch {
|
||
// 读不了就试下一个
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
interface MailConfig {
|
||
url: string;
|
||
agentName: string;
|
||
agentKey: string;
|
||
}
|
||
|
||
/** @returns null 表示拿不到密钥(调用方据此不注册任何工具) */
|
||
function resolveConfig(): MailConfig | null {
|
||
let url = process.env.AGENTMAIL_GATEWAY_URL || '';
|
||
let agentName = process.env.AGENTMAIL_AGENT_NAME || '';
|
||
let agentKey = process.env.AGENTMAIL_AGENT_KEY || '';
|
||
|
||
if (!agentKey) {
|
||
const envFile = process.env.AGENTMAIL_ENV_FILE || DEFAULT_ENV_FILE;
|
||
const parsed = parseEnvFile(envFile);
|
||
agentKey = parsed.AGENTMAIL_AGENT_KEY || '';
|
||
url = url || parsed.AGENTMAIL_GATEWAY_URL || '';
|
||
agentName = agentName || parsed.AGENTMAIL_AGENT_NAME || '';
|
||
}
|
||
|
||
if (!agentKey) agentKey = readLocalKey(process.env.AGENTMAIL_CONFIG_DIR);
|
||
if (!agentKey) return null;
|
||
|
||
return {
|
||
url: (url || DEFAULT_GATEWAY).replace(/\/+$/, ''),
|
||
agentName: agentName || 'pi',
|
||
agentKey,
|
||
};
|
||
}
|
||
|
||
export default async function (pi: ExtensionAPI): Promise<void> {
|
||
const cfg = resolveConfig();
|
||
|
||
// 动态 import 仓库里的 .mjs:它们没有 .d.ts(也不该造一份 —— 守护进程用同一份
|
||
// 对象经 customTools 注册,额外声明只会两边不同步),因此在边界上收窄成我们
|
||
// 真正依赖的最小形状。工具对象的 `parameters` 是普通 JSON Schema,而
|
||
// registerTool 的类型要求 TypeBox 的 TSchema;两者在运行期是同一套 JSON Schema
|
||
// 校验(守护进程已长期验证),差异只在静态类型上,故此处一次收窄并注明。
|
||
let registered = 0;
|
||
let notice = '';
|
||
|
||
if (!cfg) {
|
||
notice = 'pi-mail-bridge:未找到 AgentMail 密钥(AGENTMAIL_AGENT_KEY / '
|
||
+ `${process.env.AGENTMAIL_ENV_FILE || DEFAULT_ENV_FILE} / ~/.agentmail/agent.key),`
|
||
+ '邮件工具未注册。';
|
||
} else {
|
||
const gatewayMod = await import(new URL('../src/gateway.mjs', import.meta.url).href);
|
||
const toolsMod = await import(new URL('../src/tools.mjs', import.meta.url).href);
|
||
const GatewayClient = gatewayMod.GatewayClient as new (opts: {
|
||
url: string; agentName: string; agentKey: string; agentSecret: string;
|
||
}) => unknown;
|
||
const createMailTools = toolsMod.createMailTools as (opts: {
|
||
client: unknown; agentName: string; log: (msg: string) => void;
|
||
}) => MailTool[];
|
||
|
||
const tools = createMailTools({
|
||
client: new GatewayClient({
|
||
url: cfg.url,
|
||
agentName: cfg.agentName,
|
||
agentKey: cfg.agentKey,
|
||
agentSecret: '',
|
||
}),
|
||
agentName: cfg.agentName,
|
||
// 出错只进 stderr(不污染 TUI);正常路径本身不吵闹。
|
||
log: (msg: string) => console.error(`[pi-mail-tools] ${msg}`),
|
||
});
|
||
|
||
for (const tool of tools) {
|
||
if (!tool?.name || EXCLUDED_TOOLS.has(tool.name)) continue;
|
||
pi.registerTool(tool);
|
||
registered += 1;
|
||
}
|
||
}
|
||
|
||
// 只在 session_start 里报:扩展工厂可能在「不会开会话」的调用里执行
|
||
// (例如 pi --list-models),那里没有 ctx,也没有界面可显示。
|
||
pi.on('session_start', async (_event, ctx) => {
|
||
if (notice) {
|
||
ctx.ui.notify(notice, 'warning');
|
||
return;
|
||
}
|
||
ctx.ui.setStatus('pi-mail', `邮件已接入(${cfg?.agentName}@${cfg?.url},${registered} 个工具)`);
|
||
});
|
||
}
|