Files
john fb6865638a
Memind CI / Test, build, and release guards (pull_request) Successful in 1m32s
fix: align DeepSeek canary tool rounds with gate
2026-07-26 23:49:32 +08:00

527 lines
21 KiB
JavaScript

import { spawn } from 'node:child_process';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import mysql from 'mysql2/promise';
import pg from 'pg';
import { parse as parsePgConnectionString } from 'pg-connection-string';
import { loadMemindEnvFiles } from '../scripts/memind-runtime-profile.mjs';
import { assertNonProductionTarget } from './safety.mjs';
const { Client: PgClient } = pg;
const SAFE_DB_NAME = /^[a-z][a-z0-9_]{5,62}$/;
function assertLoopbackHost(host, label) {
const normalized = String(host ?? '').toLowerCase();
if (!['localhost', '127.0.0.1', '::1', '/tmp'].includes(normalized)) {
throw new Error(`${label} must use a local loopback or Unix socket`);
}
}
function quoteMysqlIdentifier(identifier) {
if (!SAFE_DB_NAME.test(identifier)) throw new Error(`Unsafe MySQL database name: ${identifier}`);
return `\`${identifier}\``;
}
function quotePgIdentifier(identifier) {
if (!SAFE_DB_NAME.test(identifier)) throw new Error(`Unsafe PostgreSQL database name: ${identifier}`);
return `"${identifier}"`;
}
export function makeIsolatedDatabaseName(runId, prefix) {
const suffix = String(runId)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 38);
const name = `${prefix}_${suffix || 'local'}`;
if (!SAFE_DB_NAME.test(name)) throw new Error(`Unsafe generated database name: ${name}`);
return name;
}
export function buildPgConnectionString(baseConnectionString, database) {
if (!SAFE_DB_NAME.test(database)) throw new Error(`Unsafe PostgreSQL database name: ${database}`);
const config = parsePgConnectionString(baseConnectionString);
assertLoopbackHost(config.host, 'PostgreSQL');
const user = encodeURIComponent(config.user ?? '');
const password = config.password ? `:${encodeURIComponent(config.password)}` : '';
const authorityHost = config.host?.startsWith('/') ? 'localhost' : config.host;
const port = config.port ? `:${config.port}` : '';
const query = config.host?.startsWith('/')
? `?host=${encodeURIComponent(config.host)}${config.port ? `&port=${encodeURIComponent(config.port)}` : ''}`
: '';
return `postgresql://${user}${password}@${authorityHost}${port}/${database}${query}`;
}
export function buildContainerPgConnectionString(baseConnectionString, database) {
const raw = String(baseConnectionString ?? '').trim();
if (!raw) return '';
const parsed = new URL(raw);
if (!SAFE_DB_NAME.test(database)) throw new Error(`Unsafe PostgreSQL database name: ${database}`);
parsed.pathname = `/${database}`;
return parsed.toString();
}
/**
* Copy only the selected local provider metadata into an isolated gate DB.
* The encrypted key material stays inside the local database boundary and is
* never logged; production/remote DSNs are rejected by assertNonProductionTarget.
*/
export async function seedSelectedProviderKeys({ sourceUrl, targetUrl }) {
assertNonProductionTarget(sourceUrl, 'source provider database');
assertNonProductionTarget(targetUrl, 'isolated provider database');
const source = await mysql.createConnection(sourceUrl);
const target = await mysql.createConnection(targetUrl);
try {
const [rows] = await source.query(
`SELECT * FROM h5_llm_provider_keys
ORDER BY is_selected DESC, is_vision_selected DESC, updated_at DESC`,
);
for (const row of rows) {
await target.execute(
`INSERT INTO h5_llm_provider_keys
(id, provider_id, provider_kind, api_url, base_path, models_json, goosed_provider_id,
engine, relay_provider, name, api_key_ciphertext, api_key_iv, api_key_tag,
default_model, status, is_selected, is_vision_selected, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
provider_id = VALUES(provider_id), provider_kind = VALUES(provider_kind),
api_url = VALUES(api_url), base_path = VALUES(base_path), models_json = VALUES(models_json),
goosed_provider_id = VALUES(goosed_provider_id), engine = VALUES(engine),
relay_provider = VALUES(relay_provider), api_key_ciphertext = VALUES(api_key_ciphertext),
api_key_iv = VALUES(api_key_iv), api_key_tag = VALUES(api_key_tag),
default_model = VALUES(default_model), status = VALUES(status),
is_selected = VALUES(is_selected), is_vision_selected = VALUES(is_vision_selected),
updated_at = VALUES(updated_at)`,
[
row.id, row.provider_id, row.provider_kind, row.api_url, row.base_path,
row.models_json, row.goosed_provider_id, row.engine, row.relay_provider,
row.name, row.api_key_ciphertext, row.api_key_iv, row.api_key_tag,
row.default_model, row.status, row.is_selected, row.is_vision_selected,
row.created_at, row.updated_at,
],
);
}
const [bindings] = await source.query('SELECT * FROM h5_llm_executor_bindings');
for (const binding of bindings) {
await target.execute(
`INSERT INTO h5_llm_executor_bindings
(id, executor, purpose, provider_key_id, model, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE provider_key_id = VALUES(provider_key_id),
model = VALUES(model), enabled = VALUES(enabled), updated_at = VALUES(updated_at)`,
[
binding.id, binding.executor, binding.purpose, binding.provider_key_id,
binding.model, binding.enabled, binding.created_at, binding.updated_at,
],
);
}
return { copied: rows.length, bindings: bindings.length };
} finally {
await Promise.all([source.end(), target.end()]);
}
}
/**
* Select the backend direct-LLM provider inside an isolated gate database.
* This deliberately never mutates the source database or any production DSN.
*/
export async function selectBackendLlmProvider({
targetUrl,
providerId = 'custom_deepseek',
model = 'deepseek-v4-pro',
}) {
assertNonProductionTarget(targetUrl, 'isolated backend LLM database');
const target = await mysql.createConnection(targetUrl);
try {
const [rows] = await target.query(
`SELECT id, provider_id, provider_kind, models_json, status
FROM h5_llm_provider_keys
WHERE provider_id = ? AND status = 'active'
ORDER BY updated_at DESC
LIMIT 1`,
[providerId],
);
const row = rows[0];
if (!row) throw new Error(`isolated backend LLM provider not found: ${providerId}`);
const supportedModels = row.provider_kind === 'custom'
? (() => {
try { return JSON.parse(row.models_json ?? '[]'); } catch { return []; }
})()
: ['deepseek-v4-pro', 'deepseek-v4-flash'];
if (!supportedModels.includes(model)) {
throw new Error(`backend LLM model is not supported by ${providerId}: ${model}`);
}
await target.query('UPDATE h5_llm_provider_keys SET is_selected = 0, updated_at = ?', [Date.now()]);
await target.execute(
`UPDATE h5_llm_provider_keys
SET is_selected = 1, default_model = ?, updated_at = ?
WHERE id = ?`,
[model, Date.now(), row.id],
);
await target.execute(
`UPDATE h5_llm_executor_bindings
SET provider_key_id = ?, model = ?, updated_at = ?
WHERE executor IN ('goose', 'aider', 'openhands') AND purpose = 'default' AND enabled = 1`,
[row.id, model, Date.now()],
);
return { providerId: row.provider_id, providerKeyId: row.id, model };
} finally {
await target.end();
}
}
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 {
// Startup can take several seconds while the isolated schema is initialized.
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`isolated Portal did not become ready: ${baseUrl}`);
}
async function waitForHttpHealth(baseUrl, child, label, timeoutMs = 30_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (child.exitCode !== null) throw new Error(`${label} exited with code ${child.exitCode}`);
try {
const response = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(1_000) });
if (response.ok) return;
} catch {
// The child can take a moment to bind its listener.
}
await new Promise((resolve) => setTimeout(resolve, 150));
}
throw new Error(`${label} did not become ready: ${baseUrl}`);
}
async function createMysqlDatabase(baseUrl, database) {
const parsed = new URL(baseUrl);
assertNonProductionTarget(baseUrl, 'MySQL URL');
assertLoopbackHost(parsed.hostname, 'MySQL');
const adminUrl = new URL(baseUrl);
adminUrl.pathname = '/mysql';
const connection = await mysql.createConnection(adminUrl.toString());
try {
await connection.query(`CREATE DATABASE ${quoteMysqlIdentifier(database)}
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
} finally {
await connection.end();
}
parsed.pathname = `/${database}`;
return parsed.toString();
}
async function dropMysqlDatabase(baseUrl, database) {
const adminUrl = new URL(baseUrl);
adminUrl.pathname = '/mysql';
const connection = await mysql.createConnection(adminUrl.toString());
try {
await connection.query(`DROP DATABASE IF EXISTS ${quoteMysqlIdentifier(database)}`);
} finally {
await connection.end();
}
}
async function createPgDatabase(baseUrl, database) {
const config = parsePgConnectionString(baseUrl);
assertLoopbackHost(config.host, 'PostgreSQL');
const admin = new PgClient({ connectionString: baseUrl, database: 'postgres' });
await admin.connect();
try {
await admin.query(`CREATE DATABASE ${quotePgIdentifier(database)}`);
} finally {
await admin.end();
}
return buildPgConnectionString(baseUrl, database);
}
async function dropPgDatabase(baseUrl, database) {
const admin = new PgClient({ connectionString: baseUrl, database: 'postgres' });
await admin.connect();
try {
await admin.query(
`SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = $1 AND pid <> pg_backend_pid()`,
[database],
);
await admin.query(`DROP DATABASE IF EXISTS ${quotePgIdentifier(database)}`);
} finally {
await admin.end();
}
}
export async function createLocalGateStack({
root = path.resolve(new URL('..', import.meta.url).pathname),
runId = `gate-${Date.now()}`,
port = 19081,
portalRoot = root,
nodeEnv = 'test',
runtimeProfile = 'local',
beforePortalStart = null,
} = {}) {
const resolvedPortalRoot = path.resolve(portalRoot);
if (
resolvedPortalRoot !== path.resolve(root)
&& resolvedPortalRoot !== path.resolve(root, '.runtime', 'portal')
) {
throw new Error('isolated Portal root must be the repository or .runtime/portal');
}
const baseEnv = { ...process.env };
loadMemindEnvFiles(root, baseEnv);
if (!baseEnv.DATABASE_URL) throw new Error('Local release gate requires DATABASE_URL');
if (!baseEnv.MINDSPACE_USERDATA_PG_URL) {
throw new Error('Local release gate requires MINDSPACE_USERDATA_PG_URL');
}
assertNonProductionTarget(baseEnv.DATABASE_URL, 'database');
assertNonProductionTarget(baseEnv.MINDSPACE_USERDATA_PG_URL, 'Page Data database');
const mysqlDatabase = makeIsolatedDatabaseName(runId, 'memind_gate');
const pgDatabase = makeIsolatedDatabaseName(runId, 'mindspace_gate');
const runRoot = path.join(root, '.release-gate', 'local', runId);
const sandboxRoot = path.join(runRoot, 'sandbox');
const usersRoot = path.join(sandboxRoot, 'users');
const storageRoot = path.join(sandboxRoot, 'data', 'mindspace');
const publishRoot = path.join(sandboxRoot, 'MindSpace');
const logPath = path.join(runRoot, 'portal.log');
const deepseekProxyLogPath = path.join(runRoot, 'deepseek-no-think.log');
// Each isolated stack gets its own compatibility-proxy port so parallel
// suites never share mutable provider state. The proxy talks directly to
// the configured backend API; it is not the H5 relay service.
const deepseekNoThinkPort = Number(
process.env.MEMIND_RELEASE_GATE_DEEPSEEK_PROXY_PORT
?? (18000 + (Number(port) % 1000)),
);
await Promise.all([
fsp.mkdir(usersRoot, { recursive: true }),
fsp.mkdir(storageRoot, { recursive: true }),
fsp.mkdir(publishRoot, { recursive: true }),
]);
// Keep the H5 root writable and isolated so packaged-runtime smoke tests
// cannot materialize MindSpace pages inside the candidate artifact. The
// platform skill catalog still comes from the packaged runtime via a
// read-only symlink.
await fsp.symlink(
path.join(resolvedPortalRoot, 'skills'),
path.join(sandboxRoot, 'skills'),
'dir',
);
const wechatBundle = path.join(resolvedPortalRoot, 'wechat-mp.bundle.mjs');
if (await fsp.stat(wechatBundle).catch(() => null)) {
await fsp.copyFile(wechatBundle, path.join(sandboxRoot, 'wechat-mp.bundle.mjs'));
}
let mysqlUrl;
let pgUrl;
let child;
let deepseekProxyChild;
let logFd;
let deepseekProxyLogFd;
let childEnv;
let baseUrl;
async function stopPortal() {
const proxyToStop = deepseekProxyChild;
const waitForProxyExit = async (timeoutMs) => {
if (!proxyToStop || proxyToStop.exitCode !== null) return true;
return Promise.race([
new Promise((resolve) => proxyToStop.once('exit', () => resolve(true))),
new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)),
]);
};
if (proxyToStop && proxyToStop.exitCode === null) {
proxyToStop.kill('SIGTERM');
const exited = await waitForProxyExit(3_000);
if (!exited && proxyToStop.exitCode === null) proxyToStop.kill('SIGKILL');
}
deepseekProxyChild = undefined;
if (deepseekProxyLogFd !== undefined) {
fs.closeSync(deepseekProxyLogFd);
deepseekProxyLogFd = undefined;
}
const processToStop = child;
const waitForExit = async (timeoutMs) => {
if (!processToStop || processToStop.exitCode !== null) return true;
return Promise.race([
new Promise((resolve) => processToStop.once('exit', () => resolve(true))),
new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)),
]);
};
if (processToStop && processToStop.exitCode === null) {
processToStop.kill('SIGTERM');
const exited = await waitForExit(5_000);
if (!exited && processToStop.exitCode === null) {
processToStop.kill('SIGKILL');
const killed = await waitForExit(5_000);
if (!killed && processToStop.exitCode === null) {
throw new Error('isolated Portal did not stop after SIGKILL');
}
}
}
child = undefined;
if (logFd !== undefined) {
fs.closeSync(logFd);
logFd = undefined;
}
}
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');
deepseekProxyLogFd = fs.openSync(deepseekProxyLogPath, 'a');
deepseekProxyChild = spawn(
process.execPath,
[path.join(resolvedPortalRoot, 'deepseek-no-think-proxy.mjs')],
{
cwd: root,
env: {
...childEnv,
MEMIND_DEEPSEEK_PROXY_ENTRYPOINT: '1',
MEMIND_DEEPSEEK_NO_THINK_PORT: String(deepseekNoThinkPort),
},
stdio: ['ignore', deepseekProxyLogFd, deepseekProxyLogFd],
},
);
await waitForHttpHealth(
`http://127.0.0.1:${deepseekNoThinkPort}`,
deepseekProxyChild,
'DeepSeek no-thinking proxy',
);
logFd = fs.openSync(logPath, 'a');
child = spawn(process.execPath, ['server.mjs'], {
cwd: resolvedPortalRoot,
env: childEnv,
stdio: ['ignore', logFd, logFd],
});
await waitForPortal(baseUrl, child);
}
try {
mysqlUrl = await createMysqlDatabase(baseEnv.DATABASE_URL, mysqlDatabase);
pgUrl = await createPgDatabase(baseEnv.MINDSPACE_USERDATA_PG_URL, pgDatabase);
childEnv = {
...baseEnv,
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}`,
DATABASE_URL: mysqlUrl,
MINDSPACE_USERDATA_PG_URL: pgUrl,
MINDSPACE_USERDATA_MCP_PG_URL: buildContainerPgConnectionString(
baseEnv.MINDSPACE_USERDATA_MCP_PG_URL,
pgDatabase,
),
// Keep code and packaged skills available while isolating all writable
// H5/MindSpace state from the candidate runtime artifact.
MEMIND_PORTAL_H5_ROOT: sandboxRoot,
H5_USERS_ROOT: usersRoot,
MINDSPACE_STORAGE_ROOT: storageRoot,
MEMIND_SHARED_PUBLISH_ROOT: publishRoot,
MEMIND_WORKSPACE_MAINTENANCE: '0',
H5_RELAY_BOOTSTRAP_DISABLED: '1',
// Keep the selected backend provider direct while enabling the local
// DeepSeek protocol compatibility shim. This is not the H5 relay: it
// only prevents dropped reasoning_content from turning tool rounds into
// provider HTTP 400s.
MEMIND_DEEPSEEK_DISABLE_THINKING: '1',
MEMIND_DEEPSEEK_NO_THINK_PORT: String(deepseekNoThinkPort),
// The isolated gate must exercise the same shadow validation contract
// used by production release checks; the database remains isolated.
MEMIND_ORCHESTRATOR_MODE: 'shadow',
MEMIND_ORCHESTRATOR_SHADOW_OBSERVATION_ENABLED: '1',
MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED: '1',
MEMIND_RUNTIME_REDIS_NAMESPACE: `memind:release-gate:${runId}`,
H5_ADMIN_USERNAME: 'gate_admin',
H5_ADMIN_PASSWORD: `Gate-${runId}-Admin!`,
H5_ACCESS_PASSWORD: `Gate-${runId}-Access!`,
H5_WECHAT_MP_ENABLED: '0',
H5_REMINDER_WORKER_ENABLED: '0',
MEMIND_ANALYTICS_ENABLED: '0',
MINDSPACE_ANALYTICS_ENABLED: '0',
MEMIND_RYBBIT_ENABLED: 'false',
};
baseUrl = `http://127.0.0.1:${port}`;
if (beforePortalStart) {
await beforePortalStart({
mysqlUrl,
pgUrl,
mysqlDatabase,
pgDatabase,
sandboxRoot,
});
}
await startPortal();
await fsp.writeFile(
path.join(runRoot, 'stack.json'),
`${JSON.stringify({
run_id: runId,
portal_base: baseUrl,
portal_root: path.relative(root, resolvedPortalRoot) || '.',
node_env: nodeEnv,
mysql_database: mysqlDatabase,
postgres_database: pgDatabase,
sandbox_root: path.relative(root, sandboxRoot),
production_connected: false,
}, null, 2)}\n`,
);
return {
runId,
runRoot,
sandboxRoot,
baseUrl,
mysqlUrl,
pgUrl,
mysqlDatabase,
pgDatabase,
sourceDatabaseUrl: baseEnv.DATABASE_URL,
async restartPortal() {
await stopPortal();
await startPortal();
},
async cleanup() {
await stopPortal();
const keepSandbox = process.env.MEMIND_RELEASE_GATE_KEEP_SANDBOX === '1';
const cleanupResults = await Promise.allSettled([
dropPgDatabase(baseEnv.MINDSPACE_USERDATA_PG_URL, pgDatabase),
dropMysqlDatabase(baseEnv.DATABASE_URL, mysqlDatabase),
keepSandbox ? Promise.resolve() : fsp.rm(sandboxRoot, { recursive: true, force: true }),
]);
const cleanupState = {
mysql_dropped: cleanupResults[1].status === 'fulfilled',
postgres_dropped: cleanupResults[0].status === 'fulfilled',
sandbox_removed: !keepSandbox && cleanupResults[2].status === 'fulfilled',
sandbox_preserved_for_debug: keepSandbox,
};
await fsp.writeFile(
path.join(runRoot, 'cleanup.json'),
`${JSON.stringify(cleanupState, null, 2)}\n`,
);
if (cleanupResults.some((result) => result.status === 'rejected')) {
throw new Error('isolated release gate cleanup failed');
}
},
};
} catch (error) {
if (child && child.exitCode === null) child.kill('SIGKILL');
if (deepseekProxyChild && deepseekProxyChild.exitCode === null) {
deepseekProxyChild.kill('SIGKILL');
}
if (logFd !== undefined) fs.closeSync(logFd);
if (deepseekProxyLogFd !== undefined) fs.closeSync(deepseekProxyLogFd);
if (pgUrl) await dropPgDatabase(baseEnv.MINDSPACE_USERDATA_PG_URL, pgDatabase).catch(() => {});
if (mysqlUrl) await dropMysqlDatabase(baseEnv.DATABASE_URL, mysqlDatabase).catch(() => {});
await fsp.rm(sandboxRoot, { recursive: true, force: true }).catch(() => {});
throw error;
}
}