Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"verify:mcp:stdio": "node scripts/verify-mcp-stdio.mjs",
"verify:mcp:http": "node scripts/verify-mcp-http.mjs",
"verify:mcp:oauth": "node scripts/verify-mcp-oauth-hardening.mjs",
"verify:mcp:cross": "node scripts/verify-mcp-cross.mjs",
"verify:mcp:skill": "node scripts/verify-mcp-skill.mjs",
"verify:mcp:publish": "node scripts/verify-mcp-publish.mjs",
"dev": "node --loader ts-node/esm src/index.ts"
Expand Down
68 changes: 68 additions & 0 deletions scripts/mcp-cross-lib.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// G6.6 跨网 e2e 门禁:需求检查与证据判定(纯函数,可单测)
//
// 与 `verify-mcp-cross.mjs` 分离,使「红/绿判定逻辑」可在无两台主机时被单元测试覆盖;
// 真实跨网执行仍只在环境齐备时由 verify-mcp-cross.mjs 驱动。

export const REQUIRED_ENV = [
['MEBULAR_WAN_PEER', 'A 机可达 libp2p multiaddr(含 relay circuit),用作 memory_sync.address'],
['MEBULAR_WAN_PEER_ID', 'A 机 deviceId,用作 memory_sync.peerId 并做身份核对'],
['MEBULAR_WAN_MCP_A', 'A 机 serve base URL(非环回须 TLS + auth != none,D45),如 https://A.example:7331'],
['MEBULAR_WAN_MCP_A_TOKEN', '访问 A 机 /mcp 的 bearer/oauth 令牌'],
];

export const OPTIONAL_ENV = [
['MEBULAR_WAN_MCP_B', 'B 机 serve base(默认 http://127.0.0.1:7331)'],
['MEBULAR_WAN_MCP_B_TOKEN', 'B 机 /mcp 令牌(环回 auth=none 时可省)'],
['MEBULAR_WAN_RELAY', 'relay multiaddr(预检其 TCP 可达性)'],
['MEBULAR_WAN_PEER_EGRESS', 'A 机实测公网出口 IP(用于 differentPublicNetwork;缺省则判据为 null → 失败)'],
['MEBULAR_MCP_CROSS_OUT', '证据 JSON 输出路径(默认 mcp-cross-evidence.json)'],
];

/** 检查跨网门禁所需 env;缺失即返回 ok:false 并列出 missing。 */
export function checkCrossEnv(env = process.env) {
const missing = REQUIRED_ENV.filter(([k]) => !env[k]).map(([k]) => k);
return {
ok: missing.length === 0,
missing,
config: {
peer: env.MEBULAR_WAN_PEER ?? null,
peerId: env.MEBULAR_WAN_PEER_ID ?? null,
mcpA: env.MEBULAR_WAN_MCP_A ?? null,
tokenA: env.MEBULAR_WAN_MCP_A_TOKEN ?? null,
mcpB: env.MEBULAR_WAN_MCP_B ?? 'http://127.0.0.1:7331',
tokenB: env.MEBULAR_WAN_MCP_B_TOKEN ?? null,
relay: env.MEBULAR_WAN_RELAY ?? null,
peerEgress: env.MEBULAR_WAN_PEER_EGRESS ?? null,
out: env.MEBULAR_MCP_CROSS_OUT ?? 'mcp-cross-evidence.json',
},
};
}

export function printCrossGuidance(log = console.log) {
log('');
log('G6.6 跨网 e2e 所需环境(真实两台不同公网主机 + 公网可达 relay):');
log(' 必需:');
for (const [k, d] of REQUIRED_ENV) log(` ${k} — ${d}`);
log(' 可选:');
for (const [k, d] of OPTIONAL_ENV) log(` ${k} — ${d}`);
log(' 另需:');
log(' · A/B 两机各跑 `mebular serve`:.mebular/config.json 设 network.enabled=true、');
log(' network.libp2p.listen 与 relayServers(该 relay)、relayUnlimited=true。');
log(' · A 机 serve 若供网络访问,须 TLS 且 auth != none(D45 fail-closed)。');
log(' · 带外交换 A 的 deviceId 与可达 multiaddr(无共享文件)。');
log(' 执行清单:docs.design/g6.6-cross-network-blocker-2026-09-14.md');
log(' 门禁:本脚本无上述环境时退出码 1(红),环境齐备且断言全过才退出码 0。');
}

/** 判定跨网证据是否达成 G6.6(全部为 true / 0 才算通过)。 */
export function judgeCrossEvidence(evidence) {
const failures = [];
if (evidence?.markerFound !== true) failures.push('markerFound !== true(B 未召回 A 的写入)');
if (evidence?.stateMatches !== true) failures.push('stateMatches !== true(双端 stateHash 不一致)');
if (evidence?.identityShared !== true) failures.push('identityShared !== true(未以同一主密钥完成握手同步)');
if (evidence?.differentPublicNetwork !== true) {
failures.push(`differentPublicNetwork !== true(${evidence?.differentPublicNetworkBasis ?? 'unknown'})`);
}
if (evidence?.pendingPeers !== 0) failures.push('pendingPeers !== 0');
return { passed: failures.length === 0, failures };
}
157 changes: 157 additions & 0 deletions scripts/verify-mcp-cross.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#!/usr/bin/env node
// G6.6 跨网 e2e 门禁(E1,环境齐备时)
//
// 在两台不同公网网络的主机上:A/B 各跑 `mebular serve`(network.enabled,经 relay);
// 本脚本在 B 运行,经 MCP 驱动:
// A: memory_write 标记 → A/B: memory_status(取 peerId/stateHash)
// B: memory_sync { peerId:A-deviceId, address:A-multiaddr } → B: memory_query 命中
// 产出机器可读证据 JSON,并以 judgeCrossEvidence 判定,退出码 0/1。
//
// 无环境时**退出码 1** 并打印所需环境(红门禁),绝不静默通过。
// 注意:本门禁需真实两机,不纳入 CI(CI 无环境必红);其判定逻辑由 tests/wan/mcp-cross.test.mjs 覆盖。

import { writeFile } from 'node:fs/promises';
import { createHash, randomUUID } from 'node:crypto';
import { Client } from '@modelcontextprotocol/client';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { preflightCross } from './wan-preflight.mjs';
import { lookupEgress, judgeDifferentNetwork } from './wan-egress.mjs';
import { checkCrossEnv, printCrossGuidance, judgeCrossEvidence } from './mcp-cross-lib.mjs';

const log = (line) => console.log(line);
const structuredOf = (result) => {
if (result?.structuredContent !== undefined) return result.structuredContent;
try {
return JSON.parse(result?.content?.find((c) => c.type === 'text')?.text ?? '{}');
} catch {
return null;
}
};

async function connect(base, token) {
const headers = token ? { authorization: `Bearer ${token}` } : {};
const transport = new StreamableHTTPClientTransport(new URL(`${String(base).replace(/\/$/, '')}/mcp`), {
requestInit: { headers },
});
const client = new Client({ name: 'mebular-mcp-cross', version: '0.1.0' });
await client.connect(transport);
return client;
}

log('Mebular G6.6 跨网 e2e 门禁');
log('=========================');

const env = checkCrossEnv(process.env);
if (!env.ok) {
log(`✗ 缺少跨网环境(${env.missing.join(', ')});未开始同步,退出码 1(红)。`);
printCrossGuidance(log);
process.exit(1);
}

const { config } = env;
const evidence = {
generatedAt: new Date().toISOString(),
coordination: 'stable-address+out-of-band',
peer: config.peer,
peerId: config.peerId,
relay: config.relay,
mcpA: config.mcpA,
mcpB: config.mcpB,
};

let clientA = null;
let clientB = null;
try {
// 1) 前置预检(A 的 p2p 地址与 relay 的 TCP 可达性)
const pre = await preflightCross({ peer: config.peer, peerId: config.peerId, relay: config.relay, log });
if (!pre) {
log('✗ 前置预检未通过,判失败(红)。');
process.exit(1);
}

// 2) 连接双端 MCP
clientA = await connect(config.mcpA, config.tokenA);
clientB = await connect(config.mcpB, config.tokenB);

// 3) A 基线状态:核对 deviceId 与网络运行
const statusA0 = structuredOf(await clientA.callTool({ name: 'memory_status', arguments: {} }));
const A = { deviceId: statusA0?.deviceId, peerId: statusA0?.peerId, listenAddrs: statusA0?.listenAddrs, relays: statusA0?.relays };
evidence.A = { ...A, nodeCount: statusA0?.nodeCount, stateHash: statusA0?.stateHash };
if (statusA0?.running !== true) {
log('✗ A 机 libp2p 未运行(network.enabled=false?),判失败(红)。');
process.exit(1);
}
if (statusA0?.deviceId !== config.peerId) {
log(`✗ A 机 deviceId=${statusA0?.deviceId} 与 MEBULAR_WAN_PEER_ID=${config.peerId} 不符,判失败(红)。`);
process.exit(1);
}

// 4) A 写入唯一标记
const marker = `g6.6-cross-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
const markerHash = createHash('sha256').update(marker).digest('hex').slice(0, 12);
await clientA.callTool({ name: 'memory_write', arguments: { items: [{ type: 'fact', content: marker, metadata: { g66: markerHash } }] } });
evidence.marker = marker;

// 5) B 基线状态 + 触发同步
const statusB0 = structuredOf(await clientB.callTool({ name: 'memory_status', arguments: {} }));
evidence.B = { deviceId: statusB0?.deviceId, peerId: statusB0?.peerId, listenAddrs: statusB0?.listenAddrs, relays: statusB0?.relays, nodeCountBefore: statusB0?.nodeCount };
let syncError = null;
try {
const sync = structuredOf(await clientB.callTool({ name: 'memory_sync', arguments: { peerId: config.peerId, address: config.peer } }));
evidence.sync = sync ?? null;
} catch (error) {
syncError = String(error?.message ?? error);
}

// 6) B 复查:召回 + 状态
const q = structuredOf(await clientB.callTool({ name: 'memory_query', arguments: { query: marker } }));
const statusB1 = structuredOf(await clientB.callTool({ name: 'memory_status', arguments: {} }));
evidence.markerFound = (q?.totalMatches ?? 0) >= 1;

// 7) A 复查状态
const statusA1 = structuredOf(await clientA.callTool({ name: 'memory_status', arguments: {} }));
evidence.A.nodeCount = statusA1?.nodeCount;
evidence.A.stateHash = statusA1?.stateHash;
evidence.B.nodeCount = statusB1?.nodeCount;
evidence.B.stateHash = statusB1?.stateHash;
evidence.B.pendingPeers = statusB1?.pendingPeers;
evidence.pendingPeers = statusB1?.pendingPeers;
evidence.stateMatches = Boolean(statusA1?.stateHash) && statusA1?.stateHash === statusB1?.stateHash;
evidence.identityShared = syncError === null && evidence.markerFound === true;
if (syncError) evidence.syncError = syncError;

// 8) 公网出口判据(本地实测 + A 机实测值)
const localEgress = await lookupEgress();
evidence.localEgress = localEgress;
evidence.peerEgress = { ip: config.peerEgress, source: process.env.MEBULAR_WAN_IP_ECHO ?? 'env:MEBULAR_WAN_PEER_EGRESS' };
evidence.egressService = localEgress.source;
const judge = judgeDifferentNetwork(localEgress.ip, config.peerEgress);
evidence.differentPublicNetwork = judge.value;
evidence.differentPublicNetworkBasis = judge.basis;

// 9) 判定 + 证据落盘
const verdict = judgeCrossEvidence(evidence);
evidence.passed = verdict.passed;
evidence.failures = verdict.failures;
await writeFile(config.out, `${JSON.stringify(evidence, null, 2)}\n`, 'utf-8');

log(` A deviceId=${evidence.A.deviceId} nodeCount=${evidence.A.nodeCount} stateHash=${evidence.A.stateHash}`);
log(` B nodeCount=${evidence.B.nodeCount} stateHash=${evidence.B.stateHash} pendingPeers=${evidence.B.pendingPeers}`);
log(` markerFound=${evidence.markerFound} stateMatches=${evidence.stateMatches} identityShared=${evidence.identityShared}`);
log(` egress(local/peer)=${localEgress.ip ?? 'unknown'}/${config.peerEgress ?? 'unset'} basis=${judge.basis}`);
log(` 证据写入 ${config.out}`);

if (verdict.passed) {
log('✓ G6.6 跨网 e2e 达成(E1):证据齐备且断言全过。');
process.exit(0);
}
log('✗ G6.6 未达成:');
for (const f of verdict.failures) log(` - ${f}`);
process.exit(1);
} catch (error) {
log(`✗ G6.6 执行失败:${String(error?.message ?? error)}`);
process.exit(1);
} finally {
await clientA?.close().catch(() => undefined);
await clientB?.close().catch(() => undefined);
}
56 changes: 56 additions & 0 deletions scripts/wan-egress.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// 公网出口判据(G3-P2,抽为共享模块供 wan-sync 与 G6.6 跨网门禁复用)
//
// 用出口 IP(默认经 MEBULAR_WAN_IP_ECHO)判定两端是否位于不同公网网络;
// 私网/回环/未知一律不判为「不同」,绝不以网卡接口 IP 为判据。

export const DEFAULT_IP_ECHO = 'https://api.ipify.org?format=json';

export async function lookupEgress(timeoutMs = 4000) {
const source = process.env.MEBULAR_WAN_IP_ECHO || DEFAULT_IP_ECHO;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(source, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = (await res.text()).trim();
let ip = null;
let org = null;
try {
const j = JSON.parse(text);
ip = typeof j.ip === 'string' ? j.ip : null;
org = typeof j.org === 'string' ? j.org : null;
} catch {
ip = text;
}
if (!ip || !/^[0-9a-fA-F:.]+$/.test(ip)) throw new Error('unexpected egress payload');
return { ip, org, asn: org ? org.split(/\s+/)[0] : null, source, error: null };
} catch (error) {
return { ip: null, org: null, asn: null, source, error: String(error?.message ?? error) };
} finally {
clearTimeout(timer);
}
}

export function isPrivateIp(ip) {
if (!ip) return true;
const lower = ip.toLowerCase();
if (lower === '::1' || lower.startsWith('fe80') || lower.startsWith('fc') || lower.startsWith('fd')) return true;
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip);
if (!m) return true; // 非 IPv4 且非已知公网 v6 → 保守判私网/未知
const a = Number(m[1]);
const b = Number(m[2]);
if (a === 10 || a === 127) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 169 && b === 254) return true;
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
return false;
}

/** 出口 IP 判据:任一未知/私网 → false/未知;均公网且不同 → true(绝不基于接口 IP) */
export function judgeDifferentNetwork(localIp, peerIp) {
if (!localIp || !peerIp) return { value: null, basis: 'egress-unknown' };
if (isPrivateIp(localIp) || isPrivateIp(peerIp)) return { value: false, basis: 'private-or-loopback' };
if (localIp === peerIp) return { value: false, basis: 'same-egress-ip' };
return { value: true, basis: 'distinct-public-egress' };
}
55 changes: 1 addition & 54 deletions scripts/wan-sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const selfPath = fileURLToPath(import.meta.url);
const mebular = await import(join(rootDir, 'dist', 'index.js'));
const { Mebular, IdentityManager, Libp2pProvider } = mebular;
import { preflightCross } from './wan-preflight.mjs';
import { lookupEgress, judgeDifferentNetwork } from './wan-egress.mjs';

const TYPES = ['entity', 'fact', 'episode', 'skill', 'meta'];

Expand Down Expand Up @@ -402,60 +403,6 @@ async function runRelay() {
process.on('SIGTERM', shutdown);
}

// ---------- 出口 IP 判据(P2-3:公网出口,非网卡接口) ----------

const DEFAULT_IP_ECHO = 'https://api.ipify.org?format=json';

async function lookupEgress(timeoutMs = 4000) {
const source = process.env.MEBULAR_WAN_IP_ECHO || DEFAULT_IP_ECHO;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(source, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = (await res.text()).trim();
let ip = null;
let org = null;
try {
const j = JSON.parse(text);
ip = typeof j.ip === 'string' ? j.ip : null;
org = typeof j.org === 'string' ? j.org : null;
} catch {
ip = text;
}
if (!ip || !/^[0-9a-fA-F:.]+$/.test(ip)) throw new Error('unexpected egress payload');
return { ip, org, asn: org ? org.split(/\s+/)[0] : null, source, error: null };
} catch (error) {
return { ip: null, org: null, asn: null, source, error: String(error?.message ?? error) };
} finally {
clearTimeout(timer);
}
}

function isPrivateIp(ip) {
if (!ip) return true;
const lower = ip.toLowerCase();
if (lower === '::1' || lower.startsWith('fe80') || lower.startsWith('fc') || lower.startsWith('fd')) return true;
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip);
if (!m) return true; // 非 IPv4 且非已知公网 v6 → 保守判私网/未知
const a = Number(m[1]);
const b = Number(m[2]);
if (a === 10 || a === 127) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 169 && b === 254) return true;
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
return false;
}

/** 出口 IP 判据:任一未知/私网 → false/未知;均公网且不同 → true(绝不基于接口 IP) */
function judgeDifferentNetwork(localIp, peerIp) {
if (!localIp || !peerIp) return { value: null, basis: 'egress-unknown' };
if (isPrivateIp(localIp) || isPrivateIp(peerIp)) return { value: false, basis: 'private-or-loopback' };
if (localIp === peerIp) return { value: false, basis: 'same-egress-ip' };
return { value: true, basis: 'distinct-public-egress' };
}

// ---------- peer:单端三阶段(稳定地址,无共享文件) ----------

function b64(bytes) {
Expand Down
Loading
Loading