message / tests /runtime /lifecycle.test.mjs
dot
Deliver native DSH market and authenticated cloud MCP with HF deployment
1140c43
Raw History Blame Contribute Delete
16.4 kB
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');
});