diff --git a/deploy/redeploy-plugin.sh b/deploy/redeploy-plugin.sh index 063d828..15bcd37 100755 --- a/deploy/redeploy-plugin.sh +++ b/deploy/redeploy-plugin.sh @@ -43,11 +43,11 @@ for arg in "$@"; do case "$arg" in -h|--help) usage; exit 0 ;; --stage-only) STAGE_ONLY=1 ;; - pi|opencode|dsh) PLUGIN="$arg" ;; + pi|opencode|dsh|zcode) PLUGIN="$arg" ;; *) echo "未知参数: $arg" >&2; exit 2 ;; esac done -[ -n "$PLUGIN" ] || { echo "用法: $0 [--stage-only]" >&2; exit 2; } +[ -n "$PLUGIN" ] || { echo "用法: $0 [--stage-only]" >&2; exit 2; } say() { printf '\n=== %s\n' "$*"; } ok() { printf ' [ OK ] %s\n' "$*"; } @@ -61,11 +61,18 @@ DEST="$DEST_ROOT/$PLUGIN-mail-bridge" SNAP="$DEST/$TS" STAGING="$DEST/.$TS.staging" -# 每个桥的差异集中在这张表里:入口、systemd 单元(opencode/dsh 不是独立 unit)。 +# 每个桥的差异集中在这张表里:入口、宿主、构建需求。 +# +# HOST 决定「切换之后拿什么判活」,两者完全不同: +# systemd → 重启单元 + 看网关库里有没有新心跳 +# zcode → **没有我们的单元可重启**:ZCode 是宿主,它在会话开始时读 +# `plugins.dirs`。于是判活改成「从快照起 MCP 服务器并走一遍握手」—— +# 这与「宿主加载插件」走的是同一份入口代码。 case "$PLUGIN" in - pi) ENTRY="src/index.mjs"; UNIT="pi-mail-bridge"; NEEDS_BUILD=0 ;; - opencode) ENTRY="index.js"; UNIT="opencode-serve"; NEEDS_BUILD=0 ;; - dsh) ENTRY="dist/index.js"; UNIT="dsh"; NEEDS_BUILD=1 ;; + pi) ENTRY="src/index.mjs"; UNIT="pi-mail-bridge"; HOST="systemd"; NEEDS_BUILD=0 ;; + opencode) ENTRY="index.js"; UNIT="opencode-serve"; HOST="systemd"; NEEDS_BUILD=0 ;; + dsh) ENTRY="dist/index.js"; UNIT="dsh"; HOST="systemd"; NEEDS_BUILD=1 ;; + zcode) ENTRY="src/index.mjs"; UNIT=""; HOST="zcode"; NEEDS_BUILD=0 ;; esac say "部署 $PLUGIN-mail-bridge → $SNAP" @@ -73,10 +80,17 @@ say "部署 $PLUGIN-mail-bridge → $SNAP" # ── 1. 前置断言 ──────────────────────────────────────────────── [ -d "$SRC" ] || { bad "源目录不存在: $SRC"; exit 2; } [ -f "$SRC/package.json" ] || { bad "缺少 package.json: $SRC"; exit 2; } -command -v systemctl >/dev/null 2>&1 || { bad "缺少 systemctl"; exit 2; } -[ -f "$GATEWAY_DB" ] || { bad "找不到网关库 $GATEWAY_DB(无法验证桥是否连上)"; exit 2; } -if ! systemctl cat "$UNIT" >/dev/null 2>&1; then - bad "找不到 systemd 单元 $UNIT(插件要先有一个运行宿主才能谈部署)"; exit 2 +if [ "$HOST" = "systemd" ]; then + command -v systemctl >/dev/null 2>&1 || { bad "缺少 systemctl"; exit 2; } + [ -f "$GATEWAY_DB" ] || { bad "找不到网关库 $GATEWAY_DB(无法验证桥是否连上)"; exit 2; } + if ! systemctl cat "$UNIT" >/dev/null 2>&1; then + bad "找不到 systemd 单元 $UNIT(插件要先有一个运行宿主才能谈部署)"; exit 2 + fi +else + # zcode 的宿主是应用本身:要能跑 CLI 才能自查「插件被发现了吗」。 + ZCODE_CLI=${ZCODE_CLI:-/opt/ZCode/resources/glm/zcode.cjs} + [ -f "$ZCODE_CLI" ] || { bad "找不到 ZCode CLI: $ZCODE_CLI"; exit 2; } + command -v node >/dev/null 2>&1 || { bad "缺少 node"; exit 2; } fi if [ "$NEEDS_BUILD" = 1 ]; then @@ -154,7 +168,7 @@ if [ "$STAGE_ONLY" = 1 ]; then # 干跑:把快照留在 .staging,**不切 current、不重启**。 say "干跑结束(--stage-only)" info "快照留在: $STAGING" - info "未做: 原子切换 / 重启 $UNIT / 后置验证" + info "未做: 原子切换${UNIT:+ / 重启 $UNIT} / 后置验证" info "要真部署:$0 $PLUGIN" exit 0 fi @@ -170,13 +184,74 @@ ok "current → $TS${PREV:+(上一版 $PREV)}" rollback() { if [ -n "$PREV" ] && [ -d "$DEST/$PREV" ]; then ln -sfn "$PREV" "$DEST/current" - systemctl restart "$UNIT" >/dev/null 2>&1 - warn "已回滚到 $PREV 并重启 $UNIT" + [ -n "$UNIT" ] && systemctl restart "$UNIT" >/dev/null 2>&1 + warn "已回滚到 $PREV${UNIT:+ 并重启 $UNIT}" else warn "无上一版可回滚(这是首次部署)—— 请手工处理" fi } +# ── 5b. zcode:宿主不是我们的 unit,判活方式不同 ────────────── +# 判据:**从快照里起一次 MCP 服务器并走完握手**。 +# 这与 ZCode 加载插件走的是同一份入口代码,所以能发现「快照缺文件」 +# 「相对导入断了」「清单被改坏」这类只有加载时才暴露的问题。 +# 检查系统 MCP 握手(stdin 进、stdout 出),返回响应里的 name 字段个数(失败回声 0)。 +# +# 计数必须用 `grep -o | wc -l`(数**次数**),不能用 `grep -c`(数**行**): +# 每一条响应只占一行,tools/list 的 11 个工具名全在同一行里, +# 用 -c 会得到 2(就那么两行),于是好快照也会被判失败(实测踩过)。 +# 期望值:11 个工具 + initialize 里的 serverInfo.name = 12。 +mcp_handshake() { + local root="$1" + printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \ + | AGENTMAIL_AGENT_NAME=probe timeout 30 node "$root/mcp/server.mjs" 2>/dev/null \ + | grep -o '"name":"' | wc -l | tr -d ' ' || true +} +MIN_NAMES=11 + +if [ "$HOST" = "zcode" ]; then + say "后置验证(zcode:宿主是应用,不能重启它)" + TOOLS=$(mcp_handshake "$DEST/current") + if [ "${TOOLS:-0}" -ge "$MIN_NAMES" ]; then + ok "从快照起的 MCP 服务器握手成功,报出 $TOOLS 个 name 字段" + else + bad "快照里的 MCP 服务器握手失败(只报出 ${TOOLS:-0} 个 name 字段,期望 ≥ $MIN_NAMES)" + rollback + exit 1 + fi + # 反向对照:握手判据必须能发现坏快照,否则「全绿」没有意义。 + if [ "$(mcp_handshake "$DEST/__definitely_missing__")" -ge "$MIN_NAMES" ]; then + bad "握手判据无法发现坏路径 —— 判据本身失效,拒绝通过" + rollback + exit 1 + fi + ok "握手判据自检通过(坏路径能被发现)" + + # ZCode 从 plugins.dirs 发现插件;没有指向 current 就还没真的切换。 + LIST=$(timeout 60 node "$ZCODE_CLI" plugins list 2>/dev/null || true) + if printf '%s' "$LIST" | grep -q "agentmail@inline"; then + WHERE=$(printf '%s' "$LIST" | grep -A 1 'agentmail@inline' | grep -o '/opt/[^ ]*' | head -1) + if [ "$WHERE" = "$DEST/current" ]; then + ok "ZCode 已从快照发现插件($WHERE)" + else + warn "ZCode 已发现 agentmail,但路径是 $WHERE(不是 $DEST/current)" + info "改这一处即可:~/.zcode/cli/config.json 的 plugins.dirs" + info " \"dirs\": [\"$DEST/current\"]" + info "改完要让 ZCode 重新读取(它会话开始时读;重启应用最保险)" + fi + else + warn "ZCode 还没发现 agentmail 插件(plugins.dirs 尚未指向 current?)" + info " \"dirs\": [\"$DEST/current\"]" + fi + + say "结论: 快照已切换" + info "运行路径: $DEST/current/$ENTRY" + info "回滚命令: ln -sfn '${PREV:-$TS}' '$DEST/current'" + exit 0 +fi + # ── 6. 重启 + 后置验证 ───────────────────────────────────────── # 记下重启时刻,后面用「网关库里的 last_seen 是否比它新」判断桥真的连上了。 # **必须用 UTC。** `agents.last_seen` 是 UTC(SQLite 的 CURRENT_TIMESTAMP 语义), diff --git a/plugins/zcode-mail-bridge/lib/is-main.mjs b/plugins/zcode-mail-bridge/lib/is-main.mjs new file mode 100644 index 0000000..384694f --- /dev/null +++ b/plugins/zcode-mail-bridge/lib/is-main.mjs @@ -0,0 +1,42 @@ +/** + * 「这个模块是不是被直接执行的入口?」——**必须比真实路径**。 + * + * # 为什么不能直接比 `import.meta.url === 'file://' + process.argv[1]` + * + * ESM 的 `import.meta.url` 已经是**解析过软链的真实路径**,而 `process.argv[1]` + * 是命令行里写的那个(可能是软链)。两者在软链下不相等,于是入口判断为假、 + * `main()` 从不执行 —— 没有输出、没有报错、**退出码 0**。 + * + * 这不是假设:生产部署的布局就是软链(`/opt/agentmail/plugins//current` + * → 某个时间戳目录),第一次从快照起 MCP 服务器时正是这样「握手 0 个工具、 + * stderr 一个字都没有」。而同一个文件从仓库路径跑是正常的 —— + * 因为仓库路径没有软链。 + * + * 这类缺陷的特点是**只在部署形态下出现**,本地怎么试都是对的; + * 而它的表现(静默成功)与「功能没被调用」完全一样。 + * + * # 为什么两边都要 realpath + * + * 只解析一侧仍会在「argv 是软链、真身是另一个软链」这类组合下失配。 + * 两侧都归一到真实路径,判断才是「同一个文件」这个语义本身。 + */ + +import { realpathSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +/** + * @param {string} metaUrl 调用方传 `import.meta.url` + * @param {string} [argv1] 默认 `process.argv[1]` + * @returns {boolean} + */ +export function isMainModule(metaUrl, argv1 = process.argv[1]) { + if (!metaUrl || !argv1) return false; + try { + return realpathSync(fileURLToPath(metaUrl)) === realpathSync(argv1); + } catch { + // 文件不存在 / 权限不足:**当作不是**入口(保守)。 + // 判错的代价:要么该跑的没跑(静默),要么被 import 时误跑一遍。 + // 后者会带来副作用(起服务、改状态),更危险。 + return false; + } +} diff --git a/plugins/zcode-mail-bridge/mcp/server.mjs b/plugins/zcode-mail-bridge/mcp/server.mjs index ff67a31..337bb8d 100644 --- a/plugins/zcode-mail-bridge/mcp/server.mjs +++ b/plugins/zcode-mail-bridge/mcp/server.mjs @@ -25,6 +25,7 @@ import { createInterface } from 'node:readline'; import { GatewayClient } from '../lib/gateway.mjs'; import { buildTools, indexTools } from '../lib/tools.mjs'; import { handleLine, SERVER_NAME, SERVER_VERSION } from '../lib/mcp-rpc.mjs'; +import { isMainModule } from '../lib/is-main.mjs'; const log = (...parts) => console.error('[agentmail-mcp]', ...parts); @@ -92,9 +93,12 @@ export async function main() { }); } -// 直接执行时启动;被 import 时只导出(便于测试与宿主按需调用)。 -const isDirect = process.argv[1] && import.meta.url === `file://${process.argv[1]}`; -if (isDirect) { +// 直接执行时启动;被 import 时只导出。 +// +// 判断**必须解析软链**(见 lib/is-main.mjs):生产布局是 `current` 软链, +// 直接比 `import.meta.url === 'file://'+argv[1]` 会判假 —— 服务器什么都不做、 +// 无输出、退出码 0(实测)。 +if (isMainModule(import.meta.url)) { main().catch(error => { log('致命错误:', error?.stack || error); process.exit(1); diff --git a/plugins/zcode-mail-bridge/src/index.mjs b/plugins/zcode-mail-bridge/src/index.mjs index f29209c..68c2eee 100644 --- a/plugins/zcode-mail-bridge/src/index.mjs +++ b/plugins/zcode-mail-bridge/src/index.mjs @@ -46,6 +46,7 @@ import { explicitSendsFile, readExplicitSends } from '../lib/explicit-sends.mjs' import { zcodeModeForTier, modeReachesPermissionHook, describeTier } from './turn-mode.mjs'; import { buildMailPrompt, replySubject, renderTurnFailure } from './prompt.mjs'; import { runTurn, DEFAULT_CLI } from './zcode-run.mjs'; +import { isMainModule } from '../lib/is-main.mjs'; const log = (...parts) => console.error('[zcode-mail-bridge]', ...parts); @@ -391,8 +392,11 @@ async function main() { } // 直接执行时启动;被 import 时只导出(测试要用 createDriver)。 -const isDirect = process.argv[1] && import.meta.url === `file://${process.argv[1]}`; -if (isDirect) { +// +// 判断**必须解析软链**(见 lib/is-main.mjs):生产布局是 `current` 软链, +// 直接比字符串会让驱动什么都不做就退出(退出码 0)—— 而那看起来 +// 与「服务正常启动但没收到信」一模一样。 +if (isMainModule(import.meta.url)) { main().catch(e => { log(`启动失败:${describeError(e)}`); process.exit(1); diff --git a/plugins/zcode-mail-bridge/test/is-main.test.mjs b/plugins/zcode-mail-bridge/test/is-main.test.mjs new file mode 100644 index 0000000..7ea117a --- /dev/null +++ b/plugins/zcode-mail-bridge/test/is-main.test.mjs @@ -0,0 +1,69 @@ +/** + * 入口判断的测试 —— 这个缺陷只在**部署形态**下出现,所以必须专门钉住。 + * + * 实测经过:生产布局是 `current` 软链,入口用 + * `import.meta.url === 'file://' + process.argv[1]` 判断是否直接执行 —— + * `import.meta.url` 是解析过软链的真实路径,argv 是软链路径,两者不等, + * 于是 `main()` 从不执行:**没有输出、没有报错、退出码 0**。 + * + * 从仓库路径跑(无软链)完全正常,所以本地怎么试都发现不了。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { isMainModule } from '../lib/is-main.mjs'; + +test('同一个文件的真实路径 → 是入口', () => { + const url = pathToFileURL('/etc/hostname').href; + assert.equal(isMainModule(url, '/etc/hostname'), true); +}); + +test('★ 通过软链指向自己 → 仍是入口(生产布局就是软链)', async () => { + const dir = await mkdtemp(join(tmpdir(), 'zc-is-main-')); + try { + const real = join(dir, 'real.mjs'); + const link = join(dir, 'current.mjs'); + await writeFile(real, '// x\n', 'utf8'); + await symlink(real, link); + const url = pathToFileURL(real).href; + assert.equal( + isMainModule(url, link), + true, + '软链路径必须被认成同一个文件 —— 否则快照部署下入口静默不执行' + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('★ 目录软链(current → <时间戳>)下的完整路径同样成立', async () => { + // 生产的软链在**目录**这一层:/opt/...//current/mcp/server.mjs + const dir = await mkdtemp(join(tmpdir(), 'zc-is-main-d-')); + try { + await mkdir(join(dir, '20260101-000000', 'mcp'), { recursive: true }); + const real = join(dir, '20260101-000000', 'mcp', 'server.mjs'); + await writeFile(real, '// x\n', 'utf8'); + await symlink('20260101-000000', join(dir, 'current')); + assert.equal( + isMainModule(pathToFileURL(real).href, join(dir, 'current', 'mcp', 'server.mjs')), + true + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('★ 反向对照:别的文件不是入口', () => { + assert.equal(isMainModule(pathToFileURL('/etc/hostname').href, '/etc/hosts'), false); +}); + +test('缺失的 argv 或文件不存在 → 保守判否(不误跑一遍)', () => { + assert.equal(isMainModule(pathToFileURL('/etc/hostname').href, ''), false); + assert.equal(isMainModule(pathToFileURL('/etc/hostname').href, undefined), false); + assert.equal(isMainModule(pathToFileURL('/etc/hostname').href, '/no/such/file/xyz'), false); + assert.equal(isMainModule('', '/etc/hostname'), false); +});