fix(release-gate): isolate PAGE agent runs from split-service MCP routing.
Memind CI / Test, build, and release guards (pull_request) Failing after 17s
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:
@@ -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}`,
|
||||
|
||||
Reference in New Issue
Block a user