#!/usr/bin/env node /** * 容器文件上云验收(可复用) * * 用法: * node scripts/verify-upload.mjs # 默认 agent-node-lj-test * node scripts/verify-upload.mjs --agent agent-xxx # 指定容器 * node scripts/verify-upload.mjs --prompt "自定义需求" # 自定义提示词 * * ## 设计原则(用户裁决 2026-11-10) * * 🔴 提示词**绝不包含**「上传 / 云盘 / 链接 / URL / S3 / 成果」任何字眼。 * 一旦提示词里提了,测试就变成「测它听不听话」,而不是「测机制生效没」。 * * ## 判定五项 * * ① 回复正文含 https://s3.fmode.cn/ ← 用户唯一能看到的入口 * ② 该链接可访问(HTTP 200 且内容非空) * ③ 成果表自动新增记录 * ④ 正文不含 /opt/data/ 本地路径 * ⑤ 成果记录里的 name 是原始文件名(不是存储键) */ import { readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; const FUNCTIONS = 'https://server.fmode.cn/api/functions'; const FN_PROVISION = 'RP5Ht55NeX'; const FN_DELIVERABLES = 'lfYlgU7SkK'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function arg(name, fallback) { const i = process.argv.indexOf(`--${name}`); return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback; } const AGENT = arg('agent', 'agent-node-lj-test'); /** 默认提示词:纯业务需求,零上传暗示 */ const PROMPT = arg( 'prompt', `帮我写一份下周的团队例会纪要模板,Markdown 格式,包含议程、决议、待办三部分。文件名用 meeting-${Date.now()}.md`, ); const WAIT_MS = Number(arg('wait', '420000')); function loadToken() { const p = join(homedir(), '.fmode', 'config', 'user.json'); const t = JSON.parse(readFileSync(p, 'utf8'))?.sessionToken; if (typeof t !== 'string' || !t.startsWith('r:')) { throw new Error(`读不到会话凭据:${p}`); } return t; } const TOKEN = loadToken(); async function callFn(id, params) { const r = await fetch(FUNCTIONS, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, token: TOKEN, params }), }); return r.json(); } async function provision(agentId) { const j = await callFn(FN_PROVISION, { agentId }); const d = j.result ?? j; if (!d?.accessToken) throw new Error(`provision 失败:${JSON.stringify(j).slice(0, 200)}`); return d; } async function listDeliverables() { const d = await callFn(FN_DELIVERABLES, { action: 'list', agentId: AGENT, limit: 200 }); return d.deliverables ?? d.result?.deliverables ?? []; } const results = []; function check(name, ok, detail = '') { results.push({ name, ok }); console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` (${detail})` : ''}`); } async function main() { console.log(`\n=== 容器上云验收 ===`); console.log(`容器 : ${AGENT}`); console.log(`提示词 : ${PROMPT}\n`); const before = await listDeliverables(); const beforeIds = new Set(before.map((d) => d.objectId ?? d.id)); console.log(`测试前成果数: ${before.length}\n`); const prov = await provision(AGENT); const agent = await provision(AGENT); const mx = await import('matrix-js-sdk'); const client = mx.createClient({ baseUrl: prov.homeserver, accessToken: prov.accessToken, userId: prov.userMxId, }); // 新建临时会话,避免历史消息干扰判定 const room = ( await client.createRoom({ invite: [agent.agentMxId], name: `verify-upload-${Date.now()}`, preset: mx.Preset.TrustedPrivateChat, }) ).room_id; const hs = prov.homeserver.replace(/\/$/, ''); const auth = { Authorization: `Bearer ${prov.accessToken}` }; for (let i = 0; i < 30; i++) { await sleep(3000); const r = await fetch( `${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/joined_members`, { headers: auth }, ).catch(() => null); const j = r ? await r.json().catch(() => ({})) : {}; if ((j.joined ?? {})[agent.agentMxId]) break; } const sent = await client.sendEvent(room, 'm.room.message', { msgtype: 'm.text', body: PROMPT, 'm.mentions': { user_ids: [agent.agentMxId] }, }); const seen = new Set([sent.event_id]); const texts = []; const deadline = Date.now() + WAIT_MS; let url = null; while (Date.now() < deadline) { await sleep(8000); try { const r = await fetch( `${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/messages?dir=b&limit=60`, { headers: auth }, ); const j = await r.json(); for (const ev of (j.chunk ?? []).reverse()) { if (ev.type !== 'm.room.message' || seen.has(ev.event_id)) continue; if (ev.sender === prov.userMxId) continue; seen.add(ev.event_id); texts.push(String(ev.content?.body ?? '')); } const all = texts.join('\n'); const m = all.match(/https:\/\/s3\.fmode\.cn\/[^\s)"'<>]+/); if (m) { url = m[0].replace(/[.,;:。,;]+$/, ''); break; } } catch { /* 轮询抖动忽略 */ } } const prose = texts.filter((b) => !/^[*·•]?\s*(🐍|💻|🔎|📖|✍️)/.test(b.trim())); console.log('=== 容器最终回复 ==='); console.log((prose[prose.length - 1] ?? '(无回复)').slice(0, 900)); console.log(''); /* * 🔴 检查**所有**云盘链接,不只是第一个(2026-11-10 实测踩坑)。 * * 容器曾自编一个形如 `https://s3.fmode.cn/user//report/xxx.md` 的假链接 * 放在正文前面,真实链接在后面。用户点第一个 → 404。 * * 所以:抓出全部、逐个验证,任何一个不可访问都算失败。 */ const allText = texts.join('\n'); const allUrls = [...new Set((allText.match(/https:\/\/s3\.fmode\.cn\/[^\s)"'<>`]+/g) ?? []))] .map((u) => u.replace(/[.,;:。,;]+$/, '')); if (allUrls.length === 0) { check('① 回复正文含 https 链接', false, '未找到'); check('② 全部链接可访问', false, '无链接可验证'); console.log('\n--- 诊断 ---'); const anyUrl = allText.match(/https?:\/\/[^\s)"'<>`]+/); console.log(`任意 URL : ${anyUrl ? anyUrl[0] : '(无)'}`); console.log(`含本地路径 : ${/\/opt\/data\//.test(allText) ? '是(未脱敏)' : '否'}`); console.log(`收到消息数 : ${texts.length}`); } else { check('① 回复正文含 https 链接', true, `${allUrls.length} 个`); // 逐个验证,统计坏链 const bad = []; for (const u of allUrls) { try { const r = await fetch(u); const body = await r.text(); if (!r.ok || body.trim().length === 0) bad.push(`${u} → HTTP ${r.status}`); } catch (e) { bad.push(`${u} → ${e instanceof Error ? e.message : '请求失败'}`); } } check( '② 全部链接可访问', bad.length === 0, bad.length === 0 ? `${allUrls.length}/${allUrls.length} 个正常` : `坏链 ${bad.length} 个:${bad.join(' | ')}`, ); /* * ②b:坏链是否出现在**真实链接之前** —— 用户会先点的那个。 * 这是最伤体验的情况:第一个链接就是 404。 */ if (bad.length > 0) { const first = allUrls[0]; const firstBad = bad.some((b) => b.startsWith(first)); console.log( firstBad ? ` ⚠️ 第一个链接就是坏链:${first}` : ` 第一个链接可用:${first}`, ); } url = allUrls.find((u) => /\/deliverables\//.test(u)) ?? allUrls[allUrls.length - 1]; } await sleep(8000); const after = await listDeliverables(); const fresh = after.filter((d) => !beforeIds.has(d.objectId ?? d.id)); check('③ 成果表自动新增', fresh.length > 0, `+${fresh.length} 条`); check('④ 正文不含本地路径', !/\/opt\/data\//.test(prose.slice(-1).join('')), ''); const named = fresh.filter((d) => (d.artifacts ?? []).some( (a) => typeof a === 'object' && a.name && !/^\d+-[a-z0-9]+-/i.test(a.name), ), ); check('⑤ 成果 name 为原始文件名', fresh.length === 0 || named.length > 0, fresh.length ? `${named.length}/${fresh.length} 条合格` : '无新成果可验证'); /* * ⑥ 成果表未被系统文件污染(2026-11-10 踩过的坑)。 * * 实测曾把容器自己的 /opt/data/skills/fmode-storage/SKILL.md 当成果传上去, * 更糟的是上传成功后还把它从本地删了。这里检查本轮新增里有没有系统文件。 */ const SYSTEM_HINTS = [ /\/opt\/data\/(skills|git-repos|scripts|plugins|memories|cron|cache|profiles)\//i, /(^|\/)(SKILL|SOUL|MEMORY|USER|AGENTS)\.md$/i, /(^|\/)\.env$/i, /(^|\/)config\.(ya?ml|toml|json)$/i, ]; const polluted = fresh.filter((d) => (d.artifacts ?? []).some((a) => { const src = typeof a === 'object' ? String(a.source ?? '') : ''; const nm = typeof a === 'object' ? String(a.name ?? '') : String(a); return SYSTEM_HINTS.some((re) => re.test(src) || re.test(nm)); }), ); check( '⑥ 成果表未被系统文件污染', polluted.length === 0, polluted.length ? polluted.map((d) => d.title).join(', ') : '', ); const passed = results.filter((r) => r.ok).length; console.log(`\n=== 结果:${passed}/${results.length} 通过 ===`); console.log(passed === results.length ? '✅ 上云链路完整' : '❌ 存在问题,见上'); client.stopClient(); process.exit(passed === results.length ? 0 : 1); } main().catch((e) => { console.error('失败:', e instanceof Error ? e.message : e); process.exit(2); });