08feae8bef
落地 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>
169 lines
5.1 KiB
JavaScript
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');
|
|
}
|