Files
memind/goosed-proxy-boundary.mjs
john 08feae8bef feat(h5-session): Session Broker、run SSE replay 与 Finish 竞态修复
落地 H5 Session 架构 Patch 1–5(Broker 收口、Router decision、SSE taxonomy、goosed 边界检查),
并新增可选 MEMIND_RUN_STREAM_REPLAY run 事件回放与 H5 假交付 guard;修复 Finish 先于 agent-run
gate 导致 UI 永久 loading 的竞态,接入 verify:h5-session-patches 回归脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 14:19:48 +08:00

169 lines
5.1 KiB
JavaScript

import fs from 'node:fs';
import path from 'node:path';
/**
* GOOSED PROXY BOUNDARY (H5 Session architecture Patch 5)
*
* H5 chat 主链路 Portal → goosed 的唯一 adapter 是 tkmind-proxy.mjs。
* 其它模块须通过 tkmindProxy.apiFetch / apiFetchTo 间接访问,不得自建 createApiFetch。
*
* 见 docs/h5-session-architecture-20260706.md §7.2
*/
/** @typedef {{ file: string, line: number, rule: string, text: string }} GoosedBoundaryViolation */
export const GOOSED_PROXY_ADAPTER_FILE = 'tkmind-proxy.mjs';
export const GOOSED_DIRECT_ALLOWLIST = [
GOOSED_PROXY_ADAPTER_FILE,
'mindspace-agent-runner.mjs',
'mindspace-page-edit-session.mjs',
'wechat-mp.mjs',
'session-reconcile.mjs',
];
export const GOOSED_PROXY_GUARDED_FILES = [
'server.mjs',
'agent-run-gateway.mjs',
'agent-run-routes.mjs',
'direct-chat-service.mjs',
'chat-intent-router.mjs',
];
export const GOOSED_BOUNDARY_LINE_ALLOW = [
/tkmindProxy\./,
/GOOSED PROXY BOUNDARY/,
/AGENT_RUNS_REQUIRED/,
/runHandlerChain\(tkmindProxy/,
/handlers\['POST \/agent/,
/api\.(post|get|put|delete|patch)\(['"]\/agent/,
/api\.(post|get|put|delete|patch)\(['"]\/sessions/,
/req\.path\.endsWith\(['"]\/reply['"]\)/,
/req\.path\.endsWith\(['"]\/events['"]\)/,
/parseApiTargets/,
/TKMIND_API_TARGET/,
/createTkmindProxy/,
/API_TARGETS/,
/proxyFallback/,
/proxySessionEvents/,
];
export const GOOSED_BOUNDARY_FORBIDDEN = [
{ id: 'create-api-fetch', pattern: /\bcreateApiFetch\s*\(/ },
{
id: 'direct-sessions-reply',
pattern: /\bapiFetch\s*\(\s*[`'"]\/sessions\/[^`'"]+\/reply/,
},
{
id: 'direct-sessions-events',
pattern: /\bapiFetch\s*\(\s*[`'"]\/sessions\/[^`'"]+\/events/,
},
{ id: 'direct-agent-start', pattern: /\bapiFetch\s*\(\s*[`'"]\/agent\/start/ },
{ id: 'direct-agent-resume', pattern: /\bapiFetch\s*\(\s*[`'"]\/agent\/resume/ },
{
id: 'raw-goosed-target-fetch',
pattern: /fetch\s*\(\s*[`'"]https?:\/\/127\.0\.0\.1:1800[67]/,
},
];
export const GOOSED_BOUNDARY_SCAN_IGNORE = [
/^scripts\//,
/^mindspace-service\//,
/^\.runtime\//,
/^node_modules\//,
/\.test\.mjs$/,
/\.test\.ts$/,
];
function relativePosix(rootDir, absolutePath) {
return path.relative(rootDir, absolutePath).split(path.sep).join('/');
}
function isIgnoredScanPath(relativePath) {
return GOOSED_BOUNDARY_SCAN_IGNORE.some((pattern) => pattern.test(relativePath));
}
function isLineAllowed(line) {
return GOOSED_BOUNDARY_LINE_ALLOW.some((pattern) => pattern.test(line));
}
function scanFileContent(relativePath, content, { guardedOnly = false } = {}) {
/** @type {GoosedBoundaryViolation[]} */
const violations = [];
const lines = content.split('\n');
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
if (!line.trim() || line.trim().startsWith('//') || line.trim().startsWith('*')) continue;
if (isLineAllowed(line)) continue;
for (const rule of GOOSED_BOUNDARY_FORBIDDEN) {
if (!rule.pattern.test(line)) continue;
violations.push({
file: relativePath,
line: index + 1,
rule: rule.id,
text: line.trim(),
});
}
}
if (guardedOnly && violations.length === 0) return violations;
return violations;
}
export function checkGoosedProxyBoundary(rootDir, {
extraAllowlist = [],
extraGuarded = [],
scanRepo = true,
} = {}) {
/** @type {GoosedBoundaryViolation[]} */
const violations = [];
const allowlist = new Set([...GOOSED_DIRECT_ALLOWLIST, ...extraAllowlist]);
const guarded = [...GOOSED_PROXY_GUARDED_FILES, ...extraGuarded];
for (const relativePath of guarded) {
const absolutePath = path.join(rootDir, relativePath);
if (!fs.existsSync(absolutePath)) {
violations.push({
file: relativePath,
line: 0,
rule: 'missing-guarded-file',
text: 'Guarded file missing from repository',
});
continue;
}
const content = fs.readFileSync(absolutePath, 'utf8');
violations.push(...scanFileContent(relativePath, content, { guardedOnly: true }));
}
if (!scanRepo) return violations;
const queue = [rootDir];
while (queue.length > 0) {
const current = queue.pop();
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === 'node_modules' || entry.name === '.git') continue;
const absolutePath = path.join(current, entry.name);
const relativePath = relativePosix(rootDir, absolutePath);
if (entry.isDirectory()) {
queue.push(absolutePath);
continue;
}
if (!entry.name.endsWith('.mjs')) continue;
if (isIgnoredScanPath(relativePath)) continue;
if (allowlist.has(relativePath)) continue;
if (guarded.includes(relativePath)) continue;
const content = fs.readFileSync(absolutePath, 'utf8');
violations.push(...scanFileContent(relativePath, content));
}
}
return violations;
}
export function formatGoosedBoundaryViolations(violations) {
if (!violations.length) return '';
return violations
.map((item) => `${item.file}:${item.line} [${item.rule}] ${item.text}`)
.join('\n');
}