diff --git a/chat-skills.test.mjs b/chat-skills.test.mjs index 1fac23a..1b30068 100644 --- a/chat-skills.test.mjs +++ b/chat-skills.test.mjs @@ -164,7 +164,7 @@ test('buildAutoChatSkillPrefix routes an uploaded xlsx only when Excel Analyst i test('manifest enhanced search route is opt-in and uses the MindSearch skill prompt', () => { const routes = [{ skillName: 'search-enhanced', promptKey: 'search-enhanced', keywords: ['最新资料'], priority: 30 }]; - assert.match(buildAutoChatSkillPrefix('请搜索最新资料:Goose MCP', ['search-enhanced'], { skillRouterV2: true, manifestRoutes: routes }), /tkmind-search/); + assert.match(buildAutoChatSkillPrefix('请搜索最新资料:Goose MCP', ['search-enhanced'], { skillRouterV2: true, manifestRoutes: routes }), /tkmind_search/); assert.equal(buildAutoChatSkillPrefix('请搜索最新资料:Goose MCP', [], { skillRouterV2: true, manifestRoutes: routes }), ''); }); diff --git a/release-gate/local-stack.mjs b/release-gate/local-stack.mjs index c57eac8..2e4b42f 100644 --- a/release-gate/local-stack.mjs +++ b/release-gate/local-stack.mjs @@ -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}`, diff --git a/release-gate/local-stack.test.mjs b/release-gate/local-stack.test.mjs index c5ae286..8a4bf3b 100644 --- a/release-gate/local-stack.test.mjs +++ b/release-gate/local-stack.test.mjs @@ -1,10 +1,13 @@ import assert from 'node:assert/strict'; +import net from 'node:net'; import test from 'node:test'; import { + assertGatePortAvailable, buildContainerPgConnectionString, buildPgConnectionString, makeIsolatedDatabaseName, + sanitizeIsolatedGatePortalEnv, } from './local-stack.mjs'; test('isolated database names are bounded and identifier-safe', () => { @@ -41,3 +44,40 @@ test('container PostgreSQL DSN keeps credentials and host while isolating databa test('isolated database names reject empty or unsafe prefixes', () => { assert.throws(() => makeIsolatedDatabaseName('abc', '../bad'), /Unsafe/); }); + +test('sanitizeIsolatedGatePortalEnv drops split-service MCP routing', () => { + const sanitized = sanitizeIsolatedGatePortalEnv({ + MINDSPACE_SERVER_ADAPTER: 'remote', + MINDSPACE_REMOTE_BASE_URL: 'http://127.0.0.1:8082', + MINDSPACE_REMOTE_AUTH_TOKEN: 'local-dev-secret', + MINDSPACE_MCP_BASE_URL: 'http://127.0.0.1:8082', + MINDSPACE_MCP_TOKEN_SECRET: 'local-dev-secret', + MINDSPACE_AGENT_API_BASE_URL: 'http://127.0.0.1:8081/api', + }, { port: 19087, runtimeProfile: 'local' }); + + assert.equal(sanitized.MINDSPACE_SERVER_ADAPTER, 'local'); + assert.equal(sanitized.MEMIND_RUNTIME_PROFILE, 'local'); + assert.equal(sanitized.H5_PORTAL_BASE_URL, 'http://127.0.0.1:19087'); + assert.equal(sanitized.MINDSPACE_AGENT_API_BASE_URL, 'http://127.0.0.1:19087/api'); + assert.equal(sanitized.MINDSPACE_REMOTE_BASE_URL, undefined); + assert.equal(sanitized.MINDSPACE_MCP_BASE_URL, undefined); + assert.equal(sanitized.MINDSPACE_MCP_TOKEN_SECRET, undefined); +}); + +test('assertGatePortAvailable rejects occupied loopback ports', async () => { + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + await assert.rejects( + () => assertGatePortAvailable(address.port), + /already in use/, + ); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + await assertGatePortAvailable(address.port); +}); diff --git a/scripts/run-release-gate-page-scenarios.mjs b/scripts/run-release-gate-page-scenarios.mjs index b08c7c7..ed993b0 100644 --- a/scripts/run-release-gate-page-scenarios.mjs +++ b/scripts/run-release-gate-page-scenarios.mjs @@ -4,6 +4,7 @@ import path from 'node:path'; import { createLocalGateStack, + grantGateUserSkillsByUsername, selectBackendLlmProvider, seedSelectedProviderKeys, } from '../release-gate/local-stack.mjs'; @@ -16,8 +17,12 @@ const scenarioIds = [ async function runScenario(scenarioId, port) { const childTimeoutMs = Math.max( - 30_000, - Number(process.env.RELEASE_GATE_SCENARIO_PROCESS_TIMEOUT_MS ?? 360_000) || 360_000, + 60_000, + Number(process.env.RELEASE_GATE_SCENARIO_PROCESS_TIMEOUT_MS ?? 900_000) || 900_000, + ); + const stepTimeoutMs = Math.max( + 60_000, + Number(process.env.RELEASE_GATE_SCENARIO_TIMEOUT_MS ?? 900_000) || 900_000, ); const code = await new Promise((resolve, reject) => { const child = spawn( @@ -28,9 +33,7 @@ async function runScenario(scenarioId, port) { env: { ...process.env, JOHN_PASSWORD: '888888', - RELEASE_GATE_SCENARIO_TIMEOUT_MS: String( - Number(process.env.RELEASE_GATE_SCENARIO_TIMEOUT_MS ?? 300_000) || 300_000, - ), + RELEASE_GATE_SCENARIO_TIMEOUT_MS: String(stepTimeoutMs), }, stdio: 'inherit', }, @@ -77,10 +80,18 @@ async function register(username) { try { await register('john2'); - const results = await Promise.all(scenarioIds.map(async (scenarioId) => ({ - scenarioId, - code: await runScenario(scenarioId, new URL(stack.baseUrl).port), - }))); + await grantGateUserSkillsByUsername({ + targetUrl: stack.mysqlUrl, + username: 'john2', + skillNames: ['static-page-publish'], + }); + const results = []; + for (const scenarioId of scenarioIds) { + results.push({ + scenarioId, + code: await runScenario(scenarioId, new URL(stack.baseUrl).port), + }); + } for (const result of results) { console.log(`${result.code === 0 ? 'PASS' : 'FAIL'} ${result.scenarioId}`); } diff --git a/scripts/test-goosed-page-direct.mjs b/scripts/test-goosed-page-direct.mjs new file mode 100644 index 0000000..67502d4 --- /dev/null +++ b/scripts/test-goosed-page-direct.mjs @@ -0,0 +1,228 @@ +#!/usr/bin/env node +/** + * Direct goosed page smoke on TKMIND_API_TARGET (default https://127.0.0.1:18006). + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { fileURLToPath } from 'node:url'; +import { Agent, fetch } from 'undici'; + +import { buildChatSkillPrompt } from '../chat-skills.mjs'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function loadEnvFile(filePath) { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq < 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (!process.env[key]) process.env[key] = value; + } +} + +loadEnvFile(path.join(root, '.env')); + +const secret = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret'; +const base = process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006'; +const workingDir = path.resolve( + process.argv[2] ?? path.join(root, '.release-gate/local/goosed-direct-page'), +); +const targetHtml = process.argv[3] ?? 'public/suzhou-goosed-direct.html'; +const provider = process.argv[4] ?? process.env.GOOSED_PAGE_TEST_PROVIDER ?? 'custom_tkmind_relay_deepseek'; +const model = process.argv[5] ?? process.env.GOOSED_PAGE_TEST_MODEL ?? 'deepseek-chat'; +const timeoutMs = Number(process.env.GOOSED_PAGE_TEST_TIMEOUT_MS ?? 600_000); + +const dispatcher = new Agent({ connect: { rejectUnauthorized: false } }); + +async function apiFetch(pathname, init = {}) { + const headers = { + ...(init.headers ?? {}), + 'X-Secret-Key': secret, + }; + if (init.body && !headers['Content-Type']) { + headers['Content-Type'] = 'application/json'; + } + return fetch(`${base}${pathname}`, { + ...init, + headers, + dispatcher, + }); +} + +async function apiJson(pathname, body) { + const response = await apiFetch(pathname, { + method: 'POST', + body: JSON.stringify(body), + }); + const text = await response.text(); + if (!response.ok) throw new Error(`${pathname} ${response.status}: ${text.slice(0, 800)}`); + if (!text.trim()) return {}; + return JSON.parse(text); +} + +function messageVisibleText(message) { + return (message?.content ?? []) + .filter((item) => item?.type === 'text') + .map((item) => String(item.text ?? '')) + .join('\n') + .trim(); +} + +async function executeSessionReply(sessionId, requestId, prompt) { + const eventsResponse = await apiFetch(`/sessions/${sessionId}/events`, { + method: 'GET', + headers: { Accept: 'text/event-stream' }, + }); + if (!eventsResponse.ok || !eventsResponse.body) { + const text = await eventsResponse.text().catch(() => ''); + throw new Error(text || '无法建立 goosed 事件流'); + } + + const replyResponse = await apiFetch(`/sessions/${sessionId}/reply`, { + method: 'POST', + body: JSON.stringify({ + request_id: requestId, + user_message: { + role: 'user', + created: Date.now(), + content: [{ type: 'text', text: prompt }], + metadata: { userVisible: true, agentVisible: true, displayText: prompt }, + }, + }), + }); + if (!replyResponse.ok) { + const text = await replyResponse.text().catch(() => ''); + throw new Error(text || 'reply 失败'); + } + replyResponse.body?.cancel?.(); + + const reader = Readable.fromWeb(eventsResponse.body); + const decoder = new TextDecoder(); + let buffer = ''; + let messages = []; + let finishSeen = false; + let errorText = ''; + + const pushMessage = (list, message) => { + const index = list.findIndex((item) => item.id === message.id); + if (index >= 0) { + const next = [...list]; + next[index] = message; + return next; + } + return [...list, message]; + }; + + const deadline = Date.now() + timeoutMs; + for await (const chunk of reader) { + if (Date.now() > deadline) throw new Error(`goosed 事件流超时 ${timeoutMs}ms`); + buffer += decoder.decode(chunk, { stream: true }); + const frames = buffer.split('\n\n'); + buffer = frames.pop() ?? ''; + for (const frame of frames) { + let data = ''; + for (const line of frame.split('\n')) { + if (line.startsWith('data:')) data += line.slice(5).trim(); + } + if (!data) continue; + let event; + try { + event = JSON.parse(data); + } catch { + continue; + } + const routingId = event.chat_request_id ?? event.request_id; + if (routingId && routingId !== requestId) continue; + + if (event.type === 'Message' && event.message?.metadata?.userVisible !== false) { + messages = pushMessage(messages, event.message); + } else if (event.type === 'UpdateConversation') { + messages = (event.conversation ?? []).filter((item) => item.metadata?.userVisible !== false); + } else if (event.type === 'Error') { + errorText = String(event.error ?? event.message ?? 'goose 执行失败'); + throw new Error(errorText); + } else if (event.type === 'Finish') { + finishSeen = true; + break; + } + } + if (finishSeen) break; + } + + const assistantTexts = messages + .filter((item) => item.role === 'assistant') + .map((item) => messageVisibleText(item)) + .filter(Boolean); + return { + finishSeen, + combined: assistantTexts.join('\n\n').trim(), + toolCalls: messages.flatMap((item) => (item.content ?? []) + .filter((part) => part?.type === 'toolRequest' || part?.type === 'toolResponse') + .map((part) => ({ + role: item.role, + type: part.type, + name: part.toolCall?.value?.name ?? part.toolResponse?.value?.name ?? part.name ?? null, + }))), + }; +} + +async function main() { + fs.mkdirSync(path.join(workingDir, 'public'), { recursive: true }); + console.log(`goosed base: ${base}`); + console.log(`working_dir: ${workingDir}`); + console.log(`target: ${targetHtml}`); + console.log(`provider: ${provider} / ${model}`); + + const start = await apiJson('/agent/start', { working_dir: workingDir }); + const sessionId = start.id; + console.log(`session: ${sessionId}`); + + await apiJson('/agent/update_provider', { + session_id: sessionId, + provider, + model, + }); + + const skillPrefix = buildChatSkillPrompt('generate-page', 'static-page-publish'); + const userText = `${skillPrefix}请帮我做一个全新的苏州一日游攻略页面,保存为 ${targetHtml},不要修改或复用已有页面,做完直接给我链接。`; + const requestId = crypto.randomUUID(); + + const reply = await executeSessionReply(sessionId, requestId, userText); + const htmlPath = path.join(workingDir, targetHtml); + const htmlExists = fs.existsSync(htmlPath); + const htmlSize = htmlExists ? fs.statSync(htmlPath).size : 0; + + console.log('\n=== result ==='); + console.log(`finish_seen: ${reply.finishSeen}`); + console.log(`html_exists: ${htmlExists}`); + console.log(`html_bytes: ${htmlSize}`); + console.log(`tool_calls: ${reply.toolCalls.length}`); + for (const call of reply.toolCalls.slice(-12)) { + console.log(` - ${call.role} ${call.type} ${call.name ?? ''}`); + } + if (reply.combined) { + console.log('\n--- assistant excerpt ---'); + console.log(reply.combined.slice(-1500)); + } + + if (!htmlExists || htmlSize <= 0) { + console.error('\nFAIL: goosed did not materialize target HTML in working_dir'); + process.exit(1); + } + const html = fs.readFileSync(htmlPath, 'utf8'); + if (!/苏州/u.test(html)) { + console.error('\nFAIL: generated HTML missing expected keyword 苏州'); + process.exit(1); + } + console.log('\nPASS: goosed wrote deliverable HTML on 18006'); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack ?? error.message : error); + process.exit(1); +});