| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272 |
- #!/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/<uid>/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);
- });
|