import test from 'node:test'; import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { mkdtemp, mkdir, readFile, writeFile, access } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import { execFile, spawn } from 'node:child_process'; import { once } from 'node:events'; import { createInterface } from 'node:readline'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; const exec = promisify(execFile); const root = fileURLToPath(new URL('../../', import.meta.url)); const cli = join(root, 'apps/cloud/src/cli.mjs'); const temp = await mkdtemp(join(tmpdir(), 'message-lifecycle-')); const env = { ...process.env, npm_config_cache: join(temp, 'npm-cache') }; async function command(...args) { const { stdout } = await exec(process.execPath, [cli, ...args], { cwd: root, env, timeout: 90000 }); return JSON.parse(stdout); } async function pack(directory) { const { stdout } = await exec('npm', ['pack', directory, '--ignore-scripts', '--pack-destination', temp, '--json'], { cwd: root, env, timeout: 30000 }); return join(temp, JSON.parse(stdout)[0].filename); } async function hashHost() { const hash = createHash('sha256'); for (const file of ['package-lock.json', 'packages/runtime/package.json', 'packages/runtime/src/index.mjs', 'packages/runtime/src/redaction.mjs', 'apps/cloud/package.json', 'apps/cloud/src/cli.mjs']) hash.update(await readFile(join(root, file))); return hash.digest('hex'); } async function mcp(profile, action) { const client = new Client({ name: 'message-acceptance', version: '1.0.0' }); const transport = new StdioClientTransport({ command: process.execPath, args: [cli, 'mcp', profile], cwd: root, env, stderr: 'pipe' }); let stderr = ''; transport.stderr?.on('data', chunk => { stderr += chunk; }); try { await client.connect(transport); return await action(client); } catch (error) { error.message += `\nMCP stderr: ${stderr}`; throw error; } finally { await client.close(); } } async function httpMcp(profile, action) { const child = spawn(process.execPath, [cli, 'http', profile], { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'] }); const exited = once(child, 'exit'); const lines = createInterface({ input: child.stderr }); const client = new Client({ name: 'message-http-acceptance', version: '1.0.0' }); try { const url = await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('HTTP MCP 启动超时')), 15000); child.once('exit', code => { clearTimeout(timer); reject(new Error(`HTTP MCP 提前退出: ${code}`)); }); lines.on('line', line => { try { const endpoint = JSON.parse(line); if (endpoint.transport === 'http' && endpoint.url) { clearTimeout(timer); resolve(endpoint.url); } } catch { /* 启动诊断不当作地址 */ } }); }); const transport = new StreamableHTTPClientTransport(new URL(url)); await client.connect(transport); await action(client, url); await transport.terminateSession(); } finally { await client.close(); lines.close(); child.kill('SIGTERM'); const timer = setTimeout(() => child.kill('SIGKILL'), 5000); await exited; clearTimeout(timer); } } test('独立安装、重启、MCP保护、禁用、升级和profile隔离', { timeout: 240000 }, async () => { const profile = join(temp, 'profile'); const other = join(temp, 'other'); await command('init', profile); await command('init', other); const before = await hashHost(); // 消费者已经就绪后才制作独立插件,不依赖消费者源码导入或宿主构建。 const fixture = join(temp, 'external-plugin'); await mkdir(fixture); const manifest = { name: '@message/acceptance-external', version: '1.0.0', type: 'module', exports: './index.mjs', files: ['index.mjs', 'cordis.patch.yml'], peerDependencies: { '@deepseek-ai/dsh-tools': '0.2.0-rc.2', '@deepseek-ai/cordis': '4.0.4' }, dsh: { bundle: { patch: './cordis.patch.yml' } } }; const source = version => `import { defineTool } from '@deepseek-ai/dsh-tools'; export const inject=['tools']; export function apply(ctx) { for (const name of ['acceptance_version','acceptance_denied','acceptance_redacted']) ctx.tools.register(defineTool({name,description:'独立验收工具',parameters:{},output:{schema:{type:'string'},render:(_a,v)=>[{type:'text',text:v}]},async execute(){return '${version}';}})); ctx.tools.guard(exec => exec.name === 'acceptance_denied' ? '验收保护拒绝' : undefined); ctx.on('tools/post-execute',async(exec,result,next)=>exec.name === 'acceptance_redacted' ? { kind:'accept', content:[{type:'text',text:'已脱敏'}] } : next()); }`; await writeFile(join(fixture, 'package.json'), JSON.stringify(manifest)); await writeFile(join(fixture, 'cordis.patch.yml'), '- insert:\n - id: acceptance-external\n name: "@message/acceptance-external"\n'); await writeFile(join(fixture, 'index.mjs'), source('v1')); for (const directory of [join(root, 'plugins/text-tools'), join(root, 'plugins/mcp-export'), fixture]) { const result = await command('install', profile, await pack(directory)); assert.equal(result.application, 'restart-required'); assert.equal(result.packageResult.exitCode, 0); } assert.equal((await command('list', other)).tools.length, 0); await mcp(profile, async client => { const list = await client.listTools(); assert(list.tools.some(x => x.name === 'message_text_uppercase')); assert(list.tools.some(x => x.name === 'acceptance_version')); const called = await client.callTool({ name: 'message_text_uppercase', arguments: { text: 'hello dsh' } }); assert.deepEqual(called.content, [{ type: 'text', text: 'HELLO DSH' }]); assert.equal((await client.callTool({ name: 'acceptance_version' })).content[0].text, 'v1'); assert.equal((await client.callTool({ name: 'acceptance_denied' })).isError, true); assert.equal((await client.callTool({ name: 'message_text_uppercase', arguments: { text: 7 } })).isError, true); const redacted = await client.callTool({ name: 'acceptance_redacted' }); assert.equal(redacted.content[0].text, '已脱敏'); assert.equal(redacted.structuredContent, undefined); }); await httpMcp(profile, async (client, url) => { assert.equal(new URL(url).hostname, '127.0.0.1'); await assert.rejects(command('remove', profile, '@message/acceptance-external'), /profile正在运行/); await assert.rejects(command('install', profile, join(temp, 'message-text-tools-0.1.0.tgz')), /profile正在运行/); assert((await client.listTools()).tools.some(x => x.name === 'acceptance_version')); assert.equal((await client.callTool({ name: 'acceptance_version' })).content[0].text, 'v1'); assert.equal((await client.callTool({ name: 'acceptance_denied' })).isError, true); const rejected = await fetch(url, { method: 'POST', headers: { origin: 'https://untrusted.example', 'content-type': 'application/json' }, body: '{}' }); assert.equal(rejected.status, 403); }); const disabled = await command('disable', profile, '@message/acceptance-external'); assert.equal(disabled.application, 'restart-required'); await mcp(profile, async client => { assert(!(await client.listTools()).tools.some(x => x.name === 'acceptance_version')); assert.equal((await client.callTool({ name: 'acceptance_version' })).isError, true); }); await command('enable', profile, '@message/acceptance-external'); manifest.version = '1.0.1'; await writeFile(join(fixture, 'package.json'), JSON.stringify(manifest)); await writeFile(join(fixture, 'index.mjs'), source('v2')); const upgraded = await command('install', profile, await pack(fixture)); assert.equal(upgraded.application, 'restart-required'); await mcp(profile, async client => assert.equal((await client.callTool({ name: 'acceptance_version' })).content[0].text, 'v2')); const removed = await command('remove', profile, '@message/acceptance-external'); assert.equal(removed.application, 'restart-required'); assert(!(await command('list', profile)).bundles.some(x => x.name === '@message/acceptance-external')); assert.equal(await hashHost(), before, '安装与升级不得改动消费者文件'); console.log(`消费者文件 SHA256 未变: ${before}`); }); test('禁止使用仓库模板作为可写profile', async () => { await assert.rejects(command('init', join(root, 'profiles/minimal')), /profile 必须位于应用仓库之外/); }); test('保留已有根配置,专用根手改则拒绝覆盖', async () => { const profile = join(temp, 'preserve-root'); await command('init', profile); const custom = '# 用户原有配置\n- name: custom-plugin\n'; await writeFile(join(profile, 'cordis.yml'), custom); await command('list', profile); assert.equal(await readFile(join(profile, 'cordis.yml'), 'utf8'), custom); await writeFile(join(profile, '.message-root.yml'), custom); await assert.rejects(command('list', profile), /不是空根/); assert.equal(await readFile(join(profile, '.message-root.yml'), 'utf8'), custom); }); test('坏bundle拒绝执行但可以经管理命令禁用恢复', async () => { const profile = join(temp, 'broken-bundle'); await command('init', profile); const path = join(profile, 'package.json'); const manifest = JSON.parse(await readFile(path, 'utf8')); manifest.dsh.profile.bundles = ['@message/missing-bundle']; await writeFile(path, JSON.stringify(manifest)); await assert.rejects(command('call', profile, 'anything'), /missing-bundle/); const result = await command('disable', profile, '@message/missing-bundle'); assert.equal(result.application, 'restart-required'); assert.equal((await command('list', profile)).tools.length, 0); }); test('安装中SIGTERM经官方manager取消并恢复manifest', { timeout: 60000 }, async () => { const profile = join(temp, 'cancel-install'); await command('init', profile); const before = await readFile(join(profile, 'package.json'), 'utf8'); const archive = await pack(join(root, 'plugins/mcp-export')); const child = spawn(process.execPath, [cli, 'install', profile, archive], { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = '', stderr = ''; child.stdout.on('data', chunk => { stdout += chunk; }); child.stderr.on('data', chunk => { stderr += chunk; }); const exited = once(child, 'close'); let installing = false; for (let i=0; i<200; i++) { try { await access(join(profile, '.plugin-manager/run.json')); installing = true; break; } catch {} if (child.exitCode !== null) break; await new Promise(resolve => setTimeout(resolve, 25)); } assert(installing, `未观察到真实包管理进程: ${stdout} ${stderr}`); child.kill('SIGTERM'); const timer = setTimeout(() => child.kill('SIGKILL'), 25000); const outcome = await exited; clearTimeout(timer); assert(stdout, `取消未返回JSON outcome=${JSON.stringify(outcome)} stderr=${stderr}`); assert.equal(JSON.parse(stdout).application, 'cancelled', stderr); assert.equal(await readFile(join(profile, 'package.json'), 'utf8'), before); await assert.rejects(access(join(profile, '.plugin-manager/run.json'))); }); test('JSON配置保存、重启生效与错误回滚', { timeout: 60000 }, async () => { const profile = join(temp, 'configure'); await command('init', profile); await command('install', profile, await pack(join(root, 'plugins/text-tools'))); const row = (await command('list', profile)).plugins.find(item => item.moduleName === '@message/text-tools'); assert.deepEqual(row.config, {}); const result = await command('configure', profile, row.entry_id, JSON.stringify({ prefix: '配置:' })); assert.equal(result.application, 'restart-required'); const value = await command('call', profile, 'message_text_uppercase', JSON.stringify({ text: 'hello' })); assert.equal(value.content[0].text, '配置:HELLO'); const before = await readFile(join(profile, 'cordis.patch.yml'), 'utf8'); await assert.rejects(command('configure', profile, row.entry_id, JSON.stringify({ prefix: 5 }))); assert.equal(await readFile(join(profile, 'cordis.patch.yml'), 'utf8'), before); await assert.rejects(command('configure', profile, row.entry_id, JSON.stringify({ api_key: 'test-only-not-a-real-key' })), /凭据/); await assert.rejects(command('configure', profile, 'include:message-tools', '{}'), /非系统/); }); test('环境引用只在worker解析且输出不泄露真实值', { timeout: 60000 }, async () => { const profile = join(temp, 'environment-reference'); const secret = 'fixture-only-not-a-real-service-credential'; const allowed = { ...env, MESSAGE_PLUGIN_ENV_ALLOWLIST: '["SERVICE_FIXTURE"]' }; const invoke = async (extra, ...args) => { const { stdout } = await exec(process.execPath, [cli, ...args], { cwd: root, env: { ...allowed, ...extra }, timeout: 30000 }); assert(!stdout.includes(secret)); return JSON.parse(stdout); }; await command('init', profile); await command('install', profile, await pack(join(root, 'plugins/text-tools'))); await invoke({}, 'configure', profile, 'include:message-text-tools', '{"prefix":{"$env":"SERVICE_FIXTURE"}}'); const row = (await invoke({}, 'list', profile)).plugins.find(item => item.row_id === 'message-text-tools'); assert.deepEqual(row.config, { prefix: { $env: 'SERVICE_FIXTURE' } }); await assert.rejects(invoke({}, 'call', profile, 'message_text_uppercase', '{"text":"missing"}'), /缺少本profile/); const result = await invoke({ SERVICE_FIXTURE: secret }, 'call', profile, 'message_text_uppercase', '{"text":"hello"}'); assert.equal(result.content[0].text, '[REDACTED]HELLO'); assert.equal(result.value, '[REDACTED]HELLO'); await assert.rejects(invoke({}, 'configure', profile, 'include:message-text-tools', '{"prefix":{"$env":"OTHER_PROFILE_SECRET"}}'), /允许名单/); await assert.rejects(invoke({}, 'configure', profile, 'include:message-text-tools', '{"prefix":{"__jsExpr":"process.env.SERVICE_FIXTURE"}}'), /表达式/); }); test('静态管理与配置不挂载插件资源', { timeout: 60000 }, async () => { const profile = join(temp, 'static-management'); const fixture = join(temp, 'resource-plugin'); const marker = join(temp, 'resource-mounts'); await mkdir(fixture); await writeFile(join(fixture, 'package.json'), JSON.stringify({ name: '@message/resource-fixture', version: '1.0.0', type: 'module', exports: './index.mjs', files: ['index.mjs', 'cordis.patch.yml'], dependencies: { '@deepseek-ai/schemastery': '3.18.4' }, peerDependencies: { '@deepseek-ai/dsh-tools': '0.2.0-rc.2', '@deepseek-ai/cordis': '4.0.4' }, dsh: { bundle: { patch: './cordis.patch.yml' } } })); await writeFile(join(fixture, 'cordis.patch.yml'), '- insert:\n - id: resource-fixture\n name: "@message/resource-fixture"\n'); await writeFile(join(fixture, 'index.mjs'), `import {appendFileSync} from 'node:fs';import z from '@deepseek-ai/schemastery';import {defineTool} from '@deepseek-ai/dsh-tools';export const inject=['tools'];export const Config=z.object({prefix:z.string().default('')});export function apply(ctx,c){appendFileSync(${JSON.stringify(marker)},'mount\\n');ctx.tools.register(defineTool({name:'resource_tool',description:'resource fixture',parameters:{},output:{schema:{type:'string'},render:(_a,v)=>[{type:'text',text:v}]},async execute(){return c.prefix+'ok';}}));}`); await command('init', profile); await command('install', profile, await pack(fixture)); await command('list', profile); await command('configure', profile, 'include:resource-fixture', '{"prefix":"configured:"}'); await command('disable', profile, '@message/resource-fixture'); await command('enable', profile, '@message/resource-fixture'); await assert.rejects(access(marker)); assert.equal((await command('call', profile, 'resource_tool')).content[0].text, 'configured:ok'); assert.equal(await readFile(marker, 'utf8'), 'mount\n'); await command('list', profile); assert.equal(await readFile(marker, 'utf8'), 'mount\n'); });