verify-upload.mjs 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. #!/usr/bin/env node
  2. /**
  3. * 容器文件上云验收(可复用)
  4. *
  5. * 用法:
  6. * node scripts/verify-upload.mjs # 默认 agent-node-lj-test
  7. * node scripts/verify-upload.mjs --agent agent-xxx # 指定容器
  8. * node scripts/verify-upload.mjs --prompt "自定义需求" # 自定义提示词
  9. *
  10. * ## 设计原则(用户裁决 2026-11-10)
  11. *
  12. * 🔴 提示词**绝不包含**「上传 / 云盘 / 链接 / URL / S3 / 成果」任何字眼。
  13. * 一旦提示词里提了,测试就变成「测它听不听话」,而不是「测机制生效没」。
  14. *
  15. * ## 判定五项
  16. *
  17. * ① 回复正文含 https://s3.fmode.cn/ ← 用户唯一能看到的入口
  18. * ② 该链接可访问(HTTP 200 且内容非空)
  19. * ③ 成果表自动新增记录
  20. * ④ 正文不含 /opt/data/ 本地路径
  21. * ⑤ 成果记录里的 name 是原始文件名(不是存储键)
  22. */
  23. import { readFileSync } from 'node:fs';
  24. import { homedir } from 'node:os';
  25. import { join } from 'node:path';
  26. const FUNCTIONS = 'https://server.fmode.cn/api/functions';
  27. const FN_PROVISION = 'RP5Ht55NeX';
  28. const FN_DELIVERABLES = 'lfYlgU7SkK';
  29. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  30. function arg(name, fallback) {
  31. const i = process.argv.indexOf(`--${name}`);
  32. return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
  33. }
  34. const AGENT = arg('agent', 'agent-node-lj-test');
  35. /** 默认提示词:纯业务需求,零上传暗示 */
  36. const PROMPT = arg(
  37. 'prompt',
  38. `帮我写一份下周的团队例会纪要模板,Markdown 格式,包含议程、决议、待办三部分。文件名用 meeting-${Date.now()}.md`,
  39. );
  40. const WAIT_MS = Number(arg('wait', '420000'));
  41. function loadToken() {
  42. const p = join(homedir(), '.fmode', 'config', 'user.json');
  43. const t = JSON.parse(readFileSync(p, 'utf8'))?.sessionToken;
  44. if (typeof t !== 'string' || !t.startsWith('r:')) {
  45. throw new Error(`读不到会话凭据:${p}`);
  46. }
  47. return t;
  48. }
  49. const TOKEN = loadToken();
  50. async function callFn(id, params) {
  51. const r = await fetch(FUNCTIONS, {
  52. method: 'POST',
  53. headers: { 'Content-Type': 'application/json' },
  54. body: JSON.stringify({ id, token: TOKEN, params }),
  55. });
  56. return r.json();
  57. }
  58. async function provision(agentId) {
  59. const j = await callFn(FN_PROVISION, { agentId });
  60. const d = j.result ?? j;
  61. if (!d?.accessToken) throw new Error(`provision 失败:${JSON.stringify(j).slice(0, 200)}`);
  62. return d;
  63. }
  64. async function listDeliverables() {
  65. const d = await callFn(FN_DELIVERABLES, { action: 'list', agentId: AGENT, limit: 200 });
  66. return d.deliverables ?? d.result?.deliverables ?? [];
  67. }
  68. const results = [];
  69. function check(name, ok, detail = '') {
  70. results.push({ name, ok });
  71. console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` (${detail})` : ''}`);
  72. }
  73. async function main() {
  74. console.log(`\n=== 容器上云验收 ===`);
  75. console.log(`容器 : ${AGENT}`);
  76. console.log(`提示词 : ${PROMPT}\n`);
  77. const before = await listDeliverables();
  78. const beforeIds = new Set(before.map((d) => d.objectId ?? d.id));
  79. console.log(`测试前成果数: ${before.length}\n`);
  80. const prov = await provision(AGENT);
  81. const agent = await provision(AGENT);
  82. const mx = await import('matrix-js-sdk');
  83. const client = mx.createClient({
  84. baseUrl: prov.homeserver,
  85. accessToken: prov.accessToken,
  86. userId: prov.userMxId,
  87. });
  88. // 新建临时会话,避免历史消息干扰判定
  89. const room = (
  90. await client.createRoom({
  91. invite: [agent.agentMxId],
  92. name: `verify-upload-${Date.now()}`,
  93. preset: mx.Preset.TrustedPrivateChat,
  94. })
  95. ).room_id;
  96. const hs = prov.homeserver.replace(/\/$/, '');
  97. const auth = { Authorization: `Bearer ${prov.accessToken}` };
  98. for (let i = 0; i < 30; i++) {
  99. await sleep(3000);
  100. const r = await fetch(
  101. `${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/joined_members`,
  102. { headers: auth },
  103. ).catch(() => null);
  104. const j = r ? await r.json().catch(() => ({})) : {};
  105. if ((j.joined ?? {})[agent.agentMxId]) break;
  106. }
  107. const sent = await client.sendEvent(room, 'm.room.message', {
  108. msgtype: 'm.text',
  109. body: PROMPT,
  110. 'm.mentions': { user_ids: [agent.agentMxId] },
  111. });
  112. const seen = new Set([sent.event_id]);
  113. const texts = [];
  114. const deadline = Date.now() + WAIT_MS;
  115. let url = null;
  116. while (Date.now() < deadline) {
  117. await sleep(8000);
  118. try {
  119. const r = await fetch(
  120. `${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/messages?dir=b&limit=60`,
  121. { headers: auth },
  122. );
  123. const j = await r.json();
  124. for (const ev of (j.chunk ?? []).reverse()) {
  125. if (ev.type !== 'm.room.message' || seen.has(ev.event_id)) continue;
  126. if (ev.sender === prov.userMxId) continue;
  127. seen.add(ev.event_id);
  128. texts.push(String(ev.content?.body ?? ''));
  129. }
  130. const all = texts.join('\n');
  131. const m = all.match(/https:\/\/s3\.fmode\.cn\/[^\s)"'<>]+/);
  132. if (m) {
  133. url = m[0].replace(/[.,;:。,;]+$/, '');
  134. break;
  135. }
  136. } catch {
  137. /* 轮询抖动忽略 */
  138. }
  139. }
  140. const prose = texts.filter((b) => !/^[*·•]?\s*(🐍|💻|🔎|📖|✍️)/.test(b.trim()));
  141. console.log('=== 容器最终回复 ===');
  142. console.log((prose[prose.length - 1] ?? '(无回复)').slice(0, 900));
  143. console.log('');
  144. /*
  145. * 🔴 检查**所有**云盘链接,不只是第一个(2026-11-10 实测踩坑)。
  146. *
  147. * 容器曾自编一个形如 `https://s3.fmode.cn/user/<uid>/report/xxx.md` 的假链接
  148. * 放在正文前面,真实链接在后面。用户点第一个 → 404。
  149. *
  150. * 所以:抓出全部、逐个验证,任何一个不可访问都算失败。
  151. */
  152. const allText = texts.join('\n');
  153. const allUrls = [...new Set((allText.match(/https:\/\/s3\.fmode\.cn\/[^\s)"'<>`]+/g) ?? []))]
  154. .map((u) => u.replace(/[.,;:。,;]+$/, ''));
  155. if (allUrls.length === 0) {
  156. check('① 回复正文含 https 链接', false, '未找到');
  157. check('② 全部链接可访问', false, '无链接可验证');
  158. console.log('\n--- 诊断 ---');
  159. const anyUrl = allText.match(/https?:\/\/[^\s)"'<>`]+/);
  160. console.log(`任意 URL : ${anyUrl ? anyUrl[0] : '(无)'}`);
  161. console.log(`含本地路径 : ${/\/opt\/data\//.test(allText) ? '是(未脱敏)' : '否'}`);
  162. console.log(`收到消息数 : ${texts.length}`);
  163. } else {
  164. check('① 回复正文含 https 链接', true, `${allUrls.length} 个`);
  165. // 逐个验证,统计坏链
  166. const bad = [];
  167. for (const u of allUrls) {
  168. try {
  169. const r = await fetch(u);
  170. const body = await r.text();
  171. if (!r.ok || body.trim().length === 0) bad.push(`${u} → HTTP ${r.status}`);
  172. } catch (e) {
  173. bad.push(`${u} → ${e instanceof Error ? e.message : '请求失败'}`);
  174. }
  175. }
  176. check(
  177. '② 全部链接可访问',
  178. bad.length === 0,
  179. bad.length === 0 ? `${allUrls.length}/${allUrls.length} 个正常` : `坏链 ${bad.length} 个:${bad.join(' | ')}`,
  180. );
  181. /*
  182. * ②b:坏链是否出现在**真实链接之前** —— 用户会先点的那个。
  183. * 这是最伤体验的情况:第一个链接就是 404。
  184. */
  185. if (bad.length > 0) {
  186. const first = allUrls[0];
  187. const firstBad = bad.some((b) => b.startsWith(first));
  188. console.log(
  189. firstBad
  190. ? ` ⚠️ 第一个链接就是坏链:${first}`
  191. : ` 第一个链接可用:${first}`,
  192. );
  193. }
  194. url = allUrls.find((u) => /\/deliverables\//.test(u)) ?? allUrls[allUrls.length - 1];
  195. }
  196. await sleep(8000);
  197. const after = await listDeliverables();
  198. const fresh = after.filter((d) => !beforeIds.has(d.objectId ?? d.id));
  199. check('③ 成果表自动新增', fresh.length > 0, `+${fresh.length} 条`);
  200. check('④ 正文不含本地路径', !/\/opt\/data\//.test(prose.slice(-1).join('')), '');
  201. const named = fresh.filter((d) =>
  202. (d.artifacts ?? []).some(
  203. (a) => typeof a === 'object' && a.name && !/^\d+-[a-z0-9]+-/i.test(a.name),
  204. ),
  205. );
  206. check('⑤ 成果 name 为原始文件名', fresh.length === 0 || named.length > 0,
  207. fresh.length ? `${named.length}/${fresh.length} 条合格` : '无新成果可验证');
  208. /*
  209. * ⑥ 成果表未被系统文件污染(2026-11-10 踩过的坑)。
  210. *
  211. * 实测曾把容器自己的 /opt/data/skills/fmode-storage/SKILL.md 当成果传上去,
  212. * 更糟的是上传成功后还把它从本地删了。这里检查本轮新增里有没有系统文件。
  213. */
  214. const SYSTEM_HINTS = [
  215. /\/opt\/data\/(skills|git-repos|scripts|plugins|memories|cron|cache|profiles)\//i,
  216. /(^|\/)(SKILL|SOUL|MEMORY|USER|AGENTS)\.md$/i,
  217. /(^|\/)\.env$/i,
  218. /(^|\/)config\.(ya?ml|toml|json)$/i,
  219. ];
  220. const polluted = fresh.filter((d) =>
  221. (d.artifacts ?? []).some((a) => {
  222. const src = typeof a === 'object' ? String(a.source ?? '') : '';
  223. const nm = typeof a === 'object' ? String(a.name ?? '') : String(a);
  224. return SYSTEM_HINTS.some((re) => re.test(src) || re.test(nm));
  225. }),
  226. );
  227. check(
  228. '⑥ 成果表未被系统文件污染',
  229. polluted.length === 0,
  230. polluted.length ? polluted.map((d) => d.title).join(', ') : '',
  231. );
  232. const passed = results.filter((r) => r.ok).length;
  233. console.log(`\n=== 结果:${passed}/${results.length} 通过 ===`);
  234. console.log(passed === results.length ? '✅ 上云链路完整' : '❌ 存在问题,见上');
  235. client.stopClient();
  236. process.exit(passed === results.length ? 0 : 1);
  237. }
  238. main().catch((e) => {
  239. console.error('失败:', e instanceof Error ? e.message : e);
  240. process.exit(2);
  241. });