#!/usr/bin/env node /** * systemd 服务异常退出邮件上报器。 * * 两种调用: * service-failure-notify.mjs --report --service pi-mail-bridge.service * service-failure-notify.mjs --flush --service pi-mail-bridge.service * * `--report` 供 ExecStopPost 使用。systemd 会把 SERVICE_RESULT / EXIT_CODE / * EXIT_STATUS / INVOCATION_ID 注入命令环境;正常 stop/restart 不上报,只有 * exit-code、signal、oom-kill、timeout、watchdog 等异常结果才发信。 * * Gateway 暂时不可达时,报告只把**不含密钥**的邮件 payload 落到本地 spool; * 下次服务启动由 `--flush` 补发。relay_key 取 systemd invocation id,立即发送与 * 补发即使竞态也只会入库一封。 */ import { createHash, randomUUID } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; import { basename, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const DEFAULT_GATEWAY = 'http://127.0.0.1:8180'; const DEFAULT_RECIPIENT = 'jianf@'; const DEFAULT_SPOOL = '/var/lib/agentmail/failure-spool'; /** * Agent 身份文件目录。单元自己的 env 里未必有密钥(`zcode.service` 就没有 —— * 它是应用单元、不是桥),但同一个 Agent 的密钥一定在 `/etc/agentmail/.env`。 */ const DEFAULT_ENV_DIR = '/etc/agentmail'; /** * 早于这个时长的报告在补投时会被标成「补投」。 * * 为什么必须标:实测有 4 封 Sep-12 的崩溃告警因为网关当时不可达而落 spool, * 直到第二天重启服务才补投出去 —— 正文里的时间戳是**昨天**,而人打开邮箱时 * 会以为服务刚刚又崩了。告警的时效性就是它的全部价值。 */ const STALE_MS = 30 * 60 * 1000; const MAX_SPOOL_FILES = 100; const REQUEST_TIMEOUT_MS = 5_000; function argValue(argv, name) { const i = argv.indexOf(name); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : ''; } export function isAbnormalExit(env = process.env) { const result = String(env.SERVICE_RESULT || '').trim(); if (result) return result !== 'success'; const code = String(env.EXIT_CODE || '').trim(); const status = String(env.EXIT_STATUS || '').trim(); if (!code && !status) return false; return !(code === 'exited' && (status === '' || status === '0')); } function cleanServiceName(value, agentName) { const raw = String(value || '').trim(); if (raw) return basename(raw).replace(/[^A-Za-z0-9_.@-]/g, '-').slice(0, 96); return `${String(agentName || 'agent').replace(/[^A-Za-z0-9_.@-]/g, '-')}-bridge`; } function redact(value, env) { let out = String(value || ''); for (const secret of [env.AGENTMAIL_AGENT_KEY, env.AGENTMAIL_AGENT_SECRET]) { if (secret) out = out.split(String(secret)).join('[REDACTED]'); } return out .replace(/Bearer\s+[A-Za-z0-9._~+\/-]+/gi, 'Bearer [REDACTED]') .replace(/\b(?:ak_[A-Za-z0-9_-]+|sk-[A-Za-z0-9_-]{12,})\b/g, '[REDACTED]') .slice(0, 1_500); } /** * 本次服务启动的短标识,用于让每封故障通知各自成会话(见 subject 处的注释)。 * * 优先用 systemd 的 `INVOCATION_ID`(每次启动唯一)。它缺失时退回随机值 —— * 宁可每次不同,也不要因为拿不到标识而退回"主题相同 → 告警被护栏挡下"。 */ function invocationTag(env) { const invocation = String(env.INVOCATION_ID || '').trim(); if (invocation) return invocation.replace(/[^A-Za-z0-9]/g, '').slice(0, 8); return createHash('sha256') .update(`${Date.now()}:${randomUUID()}`) .digest('hex') .slice(0, 8); } function relayKeyFor(env, serviceName) { const invocation = String(env.INVOCATION_ID || '').trim(); if (invocation) return `service-failure:${invocation}`; const seed = `${serviceName}:${Date.now()}:${randomUUID()}`; return `service-failure:${createHash('sha256').update(seed).digest('hex')}`; } export function buildFailurePayload({ env = process.env, serviceName = '', reason = '', error = null, now = new Date(), } = {}) { // 身份优先按单元名推导 + 从 /etc/agentmail/.env 取 —— 单元自己的 env // 里未必有名字(zcode.service 就没有),而正文与目录都要用它。 const agentName = resolveIdentity(env, serviceName).name; const service = cleanServiceName(serviceName || env.AGENTMAIL_SERVICE_NAME, agentName); const result = String(env.SERVICE_RESULT || reason || 'unexpected-exit'); const exitCode = String(env.EXIT_CODE || 'unknown'); const exitStatus = String(env.EXIT_STATUS || 'unknown'); const detail = redact(error?.stack || error?.message || error || reason, env); const lines = [ `**${agentName}** 的宿主服务 **${service}** 异常终止。`, '', `- 时间:${now.toISOString()}`, `- systemd 结果:${result}`, `- 退出类型:${exitCode}`, `- 退出状态:${exitStatus}`, `- invocation:${String(env.INVOCATION_ID || 'unknown')}`, ]; if (detail) lines.push('', '**异常详情(已脱敏)**:', '```text', detail, '```'); lines.push( '', 'systemd 已按指数退避策略自动重启该服务;如果邮件连续出现,请检查:', `\`journalctl -u ${service} -n 100 --no-pager\``, ); return { to: String(env.AGENTMAIL_FAILURE_RECIPIENT || DEFAULT_RECIPIENT), // 机器可读的生成时刻:补投时据此判断这份报告有多旧(正文里的时间是给人看的) created_at_ms: now.getTime(), /* * 主题带上本次启动的 invocation 短号。 * * 服务端按 `AutoAliasFor(收件人, 主题)` 派生会话别名,所以**主题相同 = 同一条会话**。 * 崩溃循环下这一点会咬人:多封告警全落进同一条会话,而网关对会话内**连续**的中继 * 邮件有 `maxRelayHops=5` 的硬上限(防两个 Agent 互相唤醒的正当护栏)→ 第 6 封起 * 返回 403,报告只能落 spool,等下次 ExecStartPost `--flush` 才补投。 * * 实测就是这样:dsh 崩溃循环时的六条告警全部 spooled,而**服务反复崩溃正是最需要 * 告警送达的时刻**。 * * 带 invocation 让每次崩溃各自成会话,因而每次都能投出去 —— 护栏不受影响 * (它针对的是 agent↔agent 的互相唤醒,不是同一个人收多条故障通知)。 * 副作用是崩溃循环会产生多条会话而不是一条线程;对"服务在崩"这件事, * 分开计数比合并成一条更容易发现问题。 */ // 主题里写**单元名**而不是笼统的"桥服务":同一个 Agent 可能既有应用单元 // (zcode.service)又有桥单元(zcode-mail-bridge.service),崩的常常是前者。 // 实测代价:25 封标题写着"桥服务异常终止",实际是 zcode **应用**单元在崩 —— // 照标题去查桥,方向从一开始就错了。 subject: `[${agentName}] ${service} 异常终止 #${invocationTag(env)}`, body: lines.join('\n'), relay: 'summary', relay_key: relayKeyFor(env, service), }; } async function postPayload(payload, env = process.env, serviceName = '') { const identity = resolveIdentity(env, serviceName); if (!identity.key && !identity.secret) { throw new Error( `Agent 身份未配置(单元 env 与 /etc/agentmail/${identity.name}.env 里都没有 ` + 'AGENTMAIL_AGENT_KEY / AGENTMAIL_AGENT_SECRET)' ); } const base = String(env.AGENTMAIL_GATEWAY_URL || DEFAULT_GATEWAY).replace(/\/+$/, ''); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { /* * 剥掉我们自己的元数据再发。 * * `/mail/send` 是**严格解码**的(不认识的字段直接 400),而 `created_at_ms` * 只是 spool 记录用来判断"这份报告有多旧"的,不属于线上格式。 * 实测:带上它 → `400 不认识的字段 "created_at_ms"`,报告只好又回 spool。 */ const { created_at_ms: _omit, ...wire } = payload; const res = await fetch(`${base}/api/v1/mail/send`, { method: 'POST', headers: { // 两种身份都支持:有 key 走 Bearer,只有 secret 走 X-Agent-Name/Secret ...(identity.key ? { Authorization: `Bearer ${identity.key}` } : { 'X-Agent-Name': identity.name, 'X-Agent-Secret': identity.secret }), 'Content-Type': 'application/json', }, body: JSON.stringify(wire), signal: controller.signal, }); const responseBody = await res.text(); if (!res.ok) throw new Error(`Gateway HTTP ${res.status}: ${responseBody.slice(0, 300)}`); return responseBody; } finally { clearTimeout(timer); } } function safeAgentName(env) { return String(env.AGENTMAIL_AGENT_NAME || 'unknown-agent') .replace(/[^A-Za-z0-9_.@-]/g, '-') .slice(0, 96); } /** 从 systemd 单元名推导 Agent 名:`zcode.service`→zcode、`pi-mail-bridge.service`→pi。 */ export function agentNameFromService(serviceName) { const base = basename(String(serviceName || '').trim()) .replace(/\.service$/, '') .trim(); if (!base) return ''; return base .replace(/-mail-bridge$/, '') .replace(/-bridge$/, '') .replace(/-serve$/, '') .replace(/-mail$/, '') .trim(); } /** 从 `/etc/agentmail/.env` 里读 `AGENTMAIL_AGENT_KEY`(systemd EnvironmentFile 格式)。 */ export function readAgentSecretFromFile(file, varName = 'AGENTMAIL_AGENT_KEY') { try { const text = readFileSync(file, 'utf8'); const m = text.match(new RegExp(`^\\s*${varName}\\s*=\\s*(.*)$`, 'm')); if (!m) return ''; return String(m[1]).trim().replace(/^["']|["']$/g, ''); } catch { return ''; } } /** * 解析这次上报/补投该用哪个 Agent 身份。 * * 单元自己的 env 优先;缺失时按单元名推导、再从 `/etc/agentmail/.env` 取密钥。 * * 为什么需要这一步(实测故障):`zcode.service`(应用单元)崩溃时,它的 env 里 * 既没有 `AGENTMAIL_AGENT_NAME` 也没有 `AGENTMAIL_AGENT_KEY`,报告就落进了 * `unknown-agent/` 目录并一直待在那里 —— 而 **flush 只读自己那个 Agent 的目录**, * 于是 25 份 zcode 崩溃告警永久死信(它们在 2026-09-12 14:40 那轮崩溃循环里产生)。 * 「报不出去」本身还能看见,**报不出去还落进没人读的目录**才是真的丢。 */ export function resolveIdentity(env = process.env, serviceName = '') { const explicit = String(env.AGENTMAIL_AGENT_NAME || '').trim(); const derived = agentNameFromService(serviceName || env.AGENTMAIL_SERVICE_NAME || ''); const rawName = explicit || derived || 'unknown-agent'; const name = rawName.replace(/[^A-Za-z0-9_.@-]/g, '-').slice(0, 96); let key = String(env.AGENTMAIL_AGENT_KEY || '').trim(); let secret = String(env.AGENTMAIL_AGENT_SECRET || '').trim(); let keyFrom = ''; if ((!key || !secret) && name !== 'unknown-agent') { const file = join(String(env.AGENTMAIL_ENV_DIR || DEFAULT_ENV_DIR), `${name}.env`); if (!key) key = readAgentSecretFromFile(file, 'AGENTMAIL_AGENT_KEY'); // zcode 这类单元只配了 secret 没有 key,而网关的 AgentAuth 两种都认。 // 脚本原先只认 key ⇒ zcode.service 的告警永远发不出去(实测 25 封死信)。 if (!secret) secret = readAgentSecretFromFile(file, 'AGENTMAIL_AGENT_SECRET'); if (key || secret) keyFrom = file; } return { name, key, secret, keyFrom, derived: !explicit && !!derived }; } function spoolDirFor(env, serviceName = '') { const { name } = resolveIdentity(env, serviceName); return join(String(env.AGENTMAIL_FAILURE_SPOOL || DEFAULT_SPOOL), name); } function safeSpoolName(payload) { return `${createHash('sha256').update(String(payload.relay_key)).digest('hex')}.json`; } async function pruneSpool(spoolDir) { const names = (await readdir(spoolDir).catch(() => [])) .filter((name) => name.endsWith('.json')); if (names.length <= MAX_SPOOL_FILES) return; const entries = await Promise.all(names.map(async (name) => ({ name, mtime: (await stat(join(spoolDir, name))).mtimeMs, }))); entries.sort((a, b) => a.mtime - b.mtime); for (const entry of entries.slice(0, entries.length - MAX_SPOOL_FILES)) { await rm(join(spoolDir, entry.name), { force: true }); } } export async function spoolPayload(payload, env = process.env, serviceName = '') { // 每个 Agent 独立目录:否则 pi 启动时可能拿自己的密钥去补发 dsh 的报告, // 发件身份与正文主体会错位,relay_key 的幂等范围也变了。 // // 目录名走 resolveIdentity(env → 单元名推导 → /etc/agentmail/.env), // 否则没有 AGENTMAIL_AGENT_NAME 的单元(zcode.service 就是)会把报告落进 // `unknown-agent/` —— 而 flush 只读自己那个目录,等于永久死信(实测 25 封)。 const spoolDir = spoolDirFor(env, serviceName); await mkdir(spoolDir, { recursive: true, mode: 0o700 }); const target = join(spoolDir, safeSpoolName(payload)); const temp = `${target}.${process.pid}.${randomUUID()}.tmp`; await writeFile(temp, `${JSON.stringify({ payload }, null, 2)}\n`, { mode: 0o600 }); await rename(temp, target); await pruneSpool(spoolDir); return target; } export async function notifyCrash(reason, error, options = {}) { const env = options.env || process.env; const payload = buildFailurePayload({ env, serviceName: options.serviceName, reason, error, now: options.now, }); try { await postPayload(payload, env, options.serviceName || ''); return { sent: true, spooled: false, payload }; } catch (sendError) { if (options.spool === false) throw sendError; const path = await spoolPayload(payload, env, options.serviceName || ''); return { sent: false, spooled: true, path, payload, error: sendError }; } } /** * 补投 spool 里的报告。 * * 两个改动都是踩出来的: * - **失败要说原因**。原先是 `catch { failed++ }`,只报数不报因:4 封 dsh 告警 * 在 spool 里躺了一整天,日志只有 `spool: sent=0 failed=N`,没人知道卡在哪。 * - **过时的报告要标注**。Sep-12 的崩溃告警 Sep-13 才补投成功,正文时间是昨天, * 收信人只会以为服务刚刚又崩了。 */ export async function flushSpool(env = process.env, serviceName = '') { const spoolDir = spoolDirFor(env, serviceName); const names = (await readdir(spoolDir).catch(() => [])) .filter((name) => name.endsWith('.json')) .sort(); let sent = 0; let failed = 0; const errors = []; for (const name of names) { const path = join(spoolDir, name); try { const record = JSON.parse(await readFile(path, 'utf8')); const payload = await markStaleIfNeeded(record.payload, path); await postPayload(payload, env, serviceName); await rm(path, { force: true }); sent++; } catch (error) { failed++; errors.push({ file: name, reason: describeSendError(error) }); } } return { total: names.length, sent, failed, errors, spoolDir }; } /** * 补投一份**已经过时**的报告时,把这件事写在正文最前面。 * * 不标的后果(实测):Sep-12 的崩溃告警在 Sep-13 才补投成功,正文里的时间是 * 昨天,而收信人打开邮箱只会以为服务刚刚又崩了 —— 误导性的告警比没有更糟。 */ async function markStaleIfNeeded(payload, filePath) { const createdMs = Number(payload?.created_at_ms || 0) || (await stat(filePath).catch(() => null))?.mtimeMs || 0; if (!createdMs) return payload; const age = Date.now() - createdMs; if (age <= STALE_MS) return payload; const minutes = Math.round(age / 60000); const head = [ `> **补投**:这是 ${minutes} 分钟前的故障报告(当时网关不可达或上报被拒,已暂存)。`, `> 它**不代表现在仍在故障**;服务下次启动时已触发补投。`, '', ].join('\n'); return { ...payload, subject: `[补投] ${payload.subject}`, body: `${head}${payload.body}` }; } /** * 把发送失败翻译成一句能定位原因的话。 * * `postPayload` 对非 2xx 抛的是 `Gateway HTTP : ` —— 里面已经带着 * 状态码与响应体,直接打出来就够定位了。只有 fetch 本身抛(DNS/连接/超时)才真的是 * 「不可达」,此时按名字区分开。 */ function describeSendError(error) { const message = String(error?.message || error || ''); const httpMatch = message.match(/^Gateway HTTP (\d{3}): ([\s\S]*)$/); if (httpMatch) { return `网关可达,但返回 HTTP ${httpMatch[1]}:${httpMatch[2].slice(0, 300)}`; } if (error?.name === 'AbortError') return '请求超时(网关未在限定时间内响应)'; if (/fetch failed|ECONNREFUSED|ENOTFOUND|EHOSTUNREACH/i.test(message)) { return `网关不可达:${message}`; } return message || '未知错误'; } async function main(argv = process.argv.slice(2), env = process.env) { const serviceName = argValue(argv, '--service'); /* * `--flush-all`:把 spool 下**所有** Agent 目录都排空一次。 * * 为什么要它:`--flush` 只读自己那个 Agent 的目录,于是两条路都堵死 —— * ① 报告在 A 的目录里、而 A 一直没重启 → 隔一天才补投(实测 4 封); * ② 报告落进 `unknown-agent/`(单元没给身份)→ **任何** flush 都不会读它 * (实测 25 份 zcode 崩溃告警永久死信)。 * 由定时器每 10 分钟跑一次这一条,上面两种情形都不再发生。 * * 身份仍按目录逐个解析(目录名 = Agent 名 → /etc/agentmail/.env 取密钥), * 所以不会出现「pi 拿自己的密钥去发 dsh 的报告」那种错位。 */ if (argv.includes('--flush-all')) { const root = String(env.AGENTMAIL_FAILURE_SPOOL || DEFAULT_SPOOL); const dirs = (await readdir(root).catch(() => [])).filter((n) => !n.startsWith('.')); let sent = 0; let failed = 0; const errors = []; for (const dir of dirs) { const named = dir !== 'unknown-agent' && /^[A-Za-z0-9_.@-]+$/.test(dir); const sub = { ...env, AGENTMAIL_AGENT_NAME: named ? dir : '', AGENTMAIL_AGENT_KEY: '' }; const result = await flushSpool(sub, named ? dir : ''); sent += result.sent; failed += result.failed; for (const e of result.errors) errors.push({ dir, ...e }); if (!named && result.total > 0) { console.error( `[agentmail-failure-notify] \u2605 ${dir}/ 有 ${result.total} 份报告但无法确定发件身份` + `(缺 ${DEFAULT_ENV_DIR}/.env)—— 它们永远发不出去,请修单位的 AGENTMAIL_AGENT_NAME` ); } } if (sent || failed) { console.error(`[agentmail-failure-notify] flush-all: sent=${sent} failed=${failed}`); } for (const e of errors.slice(0, 10)) { console.error(`[agentmail-failure-notify] 补投失败 ${e.dir}/${String(e.file).slice(0, 12)}…:${e.reason}`); } return; } if (argv.includes('--flush')) { const result = await flushSpool(env, serviceName); if (result.sent || result.failed) { console.error(`[agentmail-failure-notify] spool: sent=${result.sent} failed=${result.failed}`); } // 只报数不报因的话,"卡了一天"这件事在日志里就是一句沉默(实测过) for (const e of result.errors) { console.error(`[agentmail-failure-notify] 补投失败 ${String(e.file).slice(0, 12)}…:${e.reason}`); } return; } if (!argv.includes('--report')) throw new Error('需要 --report 或 --flush'); if (!isAbnormalExit(env)) return; if (argv.includes('--dry-run')) { console.log(JSON.stringify(buildFailurePayload({ env, serviceName }), null, 2)); return; } const result = await notifyCrash(env.SERVICE_RESULT || 'unexpected-exit', null, { env, serviceName, }); if (result.sent) { console.error(`[agentmail-failure-notify] ${serviceName} 异常已上报`); return; } /* * 区分「连不上」与「连上了但被拒」。 * * 原先两种情况都打印「Gateway 不可达」。实测代价:切换插件时 dsh 进了崩溃循环, * 通知脚本连打六条「Gateway 不可达」并写进 spool —— 而网关**一直在正常服务** * (NRestarts=0),真实响应是 **HTTP 403**(同一会话连续中继邮件撞上防互相唤醒 * 的跳数上限,`maxRelayHops=5`)。 * * 那句话会把人送去查网络,而问题在策略层。故障通知本身给出误导性诊断, * 是「静默失败」的另一种形态。 */ console.error( `[agentmail-failure-notify] ${serviceName} 异常上报失败,报告已暂存 ${result.path}` ); console.error(`[agentmail-failure-notify] 原因:${describeSendError(result.error)}`); } const isCLI = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); if (isCLI) { main().catch((error) => { console.error(`[agentmail-failure-notify] ${error?.message || error}`); process.exitCode = 1; }); }