fix(release-gate): isolate PAGE agent runs from split-service MCP routing.
Memind CI / Test, build, and release guards (pull_request) Failing after 17s

Gate stacks inherited developer MCP env and routed sandbox-fs writes to the
8082 workspace root, so PAGE/DATA scenarios failed with ENOENT despite a
healthy goosed. Sanitize isolated portal env, grant gate skills, and align
CHAT search assertions with the tkmind_search tool name.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-30 09:55:08 +08:00
parent b553107817
commit d67ebb0c77
5 changed files with 392 additions and 14 deletions
+103 -4
View File
@@ -1,6 +1,7 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import net from 'node:net';
import path from 'node:path';
import mysql from 'mysql2/promise';
@@ -13,6 +14,37 @@ import { assertNonProductionTarget } from './safety.mjs';
const { Client: PgClient } = pg;
const SAFE_DB_NAME = /^[a-z][a-z0-9_]{5,62}$/;
const ISOLATED_GATE_REMOTE_ENV_KEYS = [
'MINDSPACE_REMOTE_BASE_URL',
'MINDSPACE_REMOTE_AUTH_TOKEN',
'MINDSPACE_MCP_BASE_URL',
'MINDSPACE_MCP_TOKEN_SECRET',
];
/**
* Release gate stacks must not inherit split-service MindSpace MCP routing from
* the developer .env. Scoped MCP tokens would target the standalone 8082 service
* and resolve workspace paths against the host H5 root instead of the isolated
* gate sandbox, causing sandbox-fs write_file/publish_page ENOENT failures.
*/
export function sanitizeIsolatedGatePortalEnv(
env,
{ port, runtimeProfile = 'local' } = {},
) {
const sanitized = { ...env };
for (const key of ISOLATED_GATE_REMOTE_ENV_KEYS) {
delete sanitized[key];
}
sanitized.MEMIND_RUNTIME_PROFILE = runtimeProfile;
sanitized.MINDSPACE_SERVER_ADAPTER = 'local';
if (port != null) {
const portalBase = `http://127.0.0.1:${port}`;
sanitized.H5_PORTAL_BASE_URL = portalBase;
sanitized.MINDSPACE_AGENT_API_BASE_URL = `${portalBase}/api`;
}
return sanitized;
}
function assertLoopbackHost(host, label) {
const normalized = String(host ?? '').toLowerCase();
if (!['localhost', '127.0.0.1', '::1', '/tmp'].includes(normalized)) {
@@ -173,14 +205,44 @@ export async function selectBackendLlmProvider({
}
}
export function assertGatePortAvailable(port, host = '127.0.0.1') {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', (error) => {
if (error?.code === 'EADDRINUSE') {
reject(new Error(
`Release gate port ${host}:${port} is already in use; stop the stale local gate process before retrying`,
));
return;
}
reject(error);
});
server.once('listening', () => {
server.close((closeError) => {
if (closeError) reject(closeError);
else resolve();
});
});
server.listen(port, host);
});
}
async function waitForPortal(baseUrl, child, timeoutMs = 60_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (child.exitCode !== null) throw new Error(`isolated Portal exited with code ${child.exitCode}`);
try {
const response = await fetch(`${baseUrl}/auth/status`, { signal: AbortSignal.timeout(1_000) });
if (response.ok) return;
} catch {
if (response.ok) {
if (child.exitCode !== null) {
throw new Error(`isolated Portal exited with code ${child.exitCode} after health check`);
}
return;
}
} catch (error) {
if (error instanceof Error && error.message.includes('isolated Portal exited')) {
throw error;
}
// Startup can take several seconds while the isolated schema is initialized.
}
await new Promise((resolve) => setTimeout(resolve, 250));
@@ -188,6 +250,42 @@ async function waitForPortal(baseUrl, child, timeoutMs = 60_000) {
throw new Error(`isolated Portal did not become ready: ${baseUrl}`);
}
export async function grantGateUserSkillsByUsername({
targetUrl,
username,
skillNames,
}) {
assertNonProductionTarget(targetUrl, 'isolated gate database');
const normalizedUsername = String(username ?? '').trim();
const skills = [...new Set(
(Array.isArray(skillNames) ? skillNames : [])
.map((name) => String(name ?? '').trim())
.filter(Boolean),
)];
if (!normalizedUsername || skills.length === 0) return { userId: null, granted: [] };
const target = await mysql.createConnection(targetUrl);
try {
const [rows] = await target.query(
'SELECT id FROM h5_users WHERE username = ? LIMIT 1',
[normalizedUsername],
);
const userId = rows[0]?.id ?? null;
if (!userId) throw new Error(`Release gate user not found for skill grant: ${normalizedUsername}`);
const now = Date.now();
for (const skillName of skills) {
await target.execute(
`INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at)
VALUES ('user', ?, ?, 1, ?)
ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), updated_at = VALUES(updated_at)`,
[userId, skillName, now],
);
}
return { userId, granted: skills };
} finally {
await target.end();
}
}
async function waitForHttpHealth(baseUrl, child, label, timeoutMs = 30_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
@@ -377,6 +475,7 @@ export async function createLocalGateStack({
async function startPortal() {
if (!childEnv || !baseUrl) throw new Error('isolated Portal environment is not initialized');
if (child && child.exitCode === null) throw new Error('isolated Portal is already running');
await assertGatePortAvailable(port);
deepseekProxyLogFd = fs.openSync(deepseekProxyLogPath, 'a');
deepseekProxyChild = spawn(
process.execPath,
@@ -408,10 +507,10 @@ export async function createLocalGateStack({
try {
mysqlUrl = await createMysqlDatabase(baseEnv.DATABASE_URL, mysqlDatabase);
pgUrl = await createPgDatabase(baseEnv.MINDSPACE_USERDATA_PG_URL, pgDatabase);
childEnv = sanitizeIsolatedGatePortalEnv(baseEnv, { port, runtimeProfile });
childEnv = {
...baseEnv,
...childEnv,
NODE_ENV: nodeEnv,
...(runtimeProfile ? { MEMIND_RUNTIME_PROFILE: runtimeProfile } : {}),
H5_HOST: '127.0.0.1',
H5_PORT: String(port),
H5_PUBLIC_BASE_URL: `http://127.0.0.1:${port}`,