@dzhechkov/cloudru-hub 0.1.12 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,162 @@
1
+ import { after, test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { spawn, spawnSync } from 'node:child_process';
7
+ import { createRequire } from 'node:module';
8
+
9
+ const require = createRequire(import.meta.url);
10
+ const cls = require('../data/tools-classification.json');
11
+ const { GROUPS, selectedToolNames } = require('../src/toolsets.js');
12
+ const pkg = path.resolve(import.meta.dirname, '..');
13
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'cloudru-safe-mcp-'));
14
+ after(() => fs.rmSync(scratch, { recursive: true, force: true }));
15
+
16
+ const fake = path.join(scratch, 'fake-engine');
17
+ const log = path.join(scratch, 'forwarded.jsonl');
18
+ fs.writeFileSync(fake, `#!/usr/bin/env node
19
+ const fs=require('fs');let b='';process.stdin.setEncoding('utf8');process.stdin.on('data',x=>{b+=x;let i;while((i=b.indexOf('\\n'))>=0){const line=b.slice(0,i);b=b.slice(i+1);if(!line)continue;const m=JSON.parse(line);fs.appendFileSync(process.env.FAKE_LOG,JSON.stringify(m)+'\\n');if(!Object.hasOwn(m,'id'))continue;let result={};if(m.method==='initialize')result={protocolVersion:'2025-06-18',capabilities:{tools:{},resources:{}},serverInfo:{name:'fake',version:'1'}};else if(m.method==='tools/list')result={tools:[{name:'vm_list'},{name:'deploy'},{name:'secret_value'},{name:'unknown_new'}]};else if(m.method==='tools/call')result={content:[{type:'text',text:'executed '+m.params.name}]};process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:m.id,result})+'\\n');}});
20
+ `);
21
+ fs.chmodSync(fake, 0o755);
22
+
23
+ const msg = (id, method, params) => JSON.stringify({ jsonrpc: '2.0', id, method, ...(params ? { params } : {}) });
24
+ function run(lines, toolsets = 'all') {
25
+ fs.writeFileSync(log, '');
26
+ const script = `const {runSafeMcp}=require(${JSON.stringify(path.join(pkg, 'src/safe-mcp.js'))});runSafeMcp({toolsets:${JSON.stringify(toolsets)},resolved:{path:${JSON.stringify(fake)},verified:true,sha256:${JSON.stringify(cls.engineSha256)}}});`;
27
+ const r = spawnSync(process.execPath, ['-e', script], { input: lines.join('\n') + '\n', encoding: 'utf8', timeout: 5000, env: { ...process.env, FAKE_LOG: log } });
28
+ assert.equal(r.status, 0, r.stderr || r.error?.message);
29
+ return { responses: r.stdout.trim().split('\n').map(JSON.parse), forwarded: fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse) };
30
+ }
31
+
32
+ test('safe proxy filters listed tools and blocks ask, deny, unknown and other methods before engine', () => {
33
+ const out = run([
34
+ msg(1, 'initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test', version: '1' } }),
35
+ msg(2, 'tools/list'),
36
+ msg(3, 'tools/call', { name: 'deploy', arguments: { confirmed: true } }),
37
+ msg(4, 'tools/call', { name: 'secret_value', arguments: {} }),
38
+ msg(5, 'tools/call', { name: 'unknown_new', arguments: {} }),
39
+ msg(6, 'resources/read', { uri: 'file:///etc/passwd' }),
40
+ msg(7, 'tools/call', { name: 'vm_list', arguments: {} }),
41
+ ]);
42
+ const byId = Object.fromEntries(out.responses.map((r) => [r.id, r]));
43
+ assert.deepEqual(byId[2].result.tools.map((t) => t.name), ['vm_list']);
44
+ assert.deepEqual(Object.keys(byId[1].result.capabilities), ['tools']);
45
+ for (const id of [3, 4, 5, 6]) assert.ok(byId[id].error, `request ${id} blocked`);
46
+ assert.match(byId[7].result.content[0].text, /executed vm_list/);
47
+ assert.deepEqual(out.forwarded.filter((m) => m.method === 'tools/call').map((m) => m.params.name), ['vm_list']);
48
+ });
49
+
50
+ test('Hermes keepalive gets the filtered tool list while the serial engine is busy', async () => {
51
+ const serial = path.join(scratch, 'serial-engine');
52
+ const serialLog = path.join(scratch, 'serial-forwarded.jsonl');
53
+ fs.writeFileSync(serial, `#!/usr/bin/env node
54
+ const fs=require('fs');let buffer='',busy=false;const queue=[];
55
+ function next(){if(busy||!queue.length)return;busy=true;const m=queue.shift();fs.appendFileSync(process.env.FAKE_LOG,JSON.stringify(m)+'\\n');
56
+ const result=m.method==='tools/list'?{tools:[{name:'vm_list'},{name:'deploy'}]}:{content:[{type:'text',text:'done'}]};
57
+ const done=()=>{process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:m.id,result})+'\\n');busy=false;next();};
58
+ if(m.method==='tools/call')setTimeout(done,1500);else done();}
59
+ process.stdin.setEncoding('utf8');process.stdin.on('data',chunk=>{buffer+=chunk;let end;while((end=buffer.indexOf('\\n'))>=0){const line=buffer.slice(0,end);buffer=buffer.slice(end+1);if(line)queue.push(JSON.parse(line));}next();});
60
+ `);
61
+ fs.chmodSync(serial, 0o755);
62
+ fs.writeFileSync(serialLog, '');
63
+ const script = `const {runSafeMcp}=require(${JSON.stringify(path.join(pkg, 'src/safe-mcp.js'))});runSafeMcp({resolved:{path:${JSON.stringify(serial)},verified:true,sha256:${JSON.stringify(cls.engineSha256)}}});`;
64
+ const child = spawn(process.execPath, ['-e', script], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, FAKE_LOG: serialLog } });
65
+ const responses = new Map();
66
+ const listeners = new Map();
67
+ let buffer = '';
68
+ child.stdout.on('data', (chunk) => {
69
+ buffer += chunk;
70
+ let end;
71
+ while ((end = buffer.indexOf('\n')) >= 0) {
72
+ const line = buffer.slice(0, end);
73
+ buffer = buffer.slice(end + 1);
74
+ if (!line) continue;
75
+ const response = JSON.parse(line);
76
+ responses.set(response.id, response);
77
+ listeners.get(response.id)?.(response);
78
+ }
79
+ });
80
+ const waitFor = (id, timeoutMs) => new Promise((resolve, reject) => {
81
+ if (responses.has(id)) return resolve(responses.get(id));
82
+ const timer = setTimeout(() => reject(new Error(`MCP response ${id} timed out`)), timeoutMs);
83
+ listeners.set(id, (response) => { clearTimeout(timer); listeners.delete(id); resolve(response); });
84
+ });
85
+ try {
86
+ child.stdin.write(msg(1, 'tools/list') + '\n');
87
+ assert.deepEqual((await waitFor(1, 2000)).result.tools.map((t) => t.name), ['vm_list']);
88
+ child.stdin.write(msg(2, 'tools/call', { name: 'vm_list', arguments: {} }) + '\n');
89
+ child.stdin.write(msg(3, 'tools/list') + '\n');
90
+ assert.deepEqual((await waitFor(3, 1000)).result.tools.map((t) => t.name), ['vm_list']);
91
+ assert.equal((await waitFor(2, 2500)).result.content[0].text, 'done');
92
+ child.stdin.end();
93
+ assert.equal(await new Promise((resolve) => child.on('exit', resolve)), 0);
94
+ const forwarded = fs.readFileSync(serialLog, 'utf8').trim().split('\n').map(JSON.parse);
95
+ assert.deepEqual(forwarded.map((m) => m.method), ['tools/list', 'tools/call']);
96
+ } finally {
97
+ child.kill();
98
+ }
99
+ });
100
+
101
+ test('Go case-insensitive JSON aliases cannot change the validated method or tool name', () => {
102
+ const out = run([
103
+ JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', Method: 'tools/call', params: { name: 'vm_list', Name: 'deploy', arguments: {} }, Params: { name: 'deploy', arguments: { confirmed: true } } }),
104
+ JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'ping', Method: 'tools/call', Params: { name: 'deploy', arguments: { confirmed: true } } }),
105
+ JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'deploy' }, Params: { name: 'vm_list' } }),
106
+ JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', Method: 'tools/call', Params: { name: 'deploy' } }),
107
+ '{"jsonrpc":"2.0","id":4,"method":"tools/call","method":"resources/read","params":{"name":"vm_list"}}',
108
+ ]);
109
+ assert.equal(out.responses.find((r) => r.id === 1).result.content[0].text, 'executed vm_list');
110
+ assert.ok(out.responses.find((r) => r.id === 3).error);
111
+ assert.ok(out.responses.find((r) => r.id === 4).error);
112
+ assert.deepEqual(out.forwarded, [
113
+ { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'vm_list', arguments: {} } },
114
+ { jsonrpc: '2.0', id: 2, method: 'ping' },
115
+ { jsonrpc: '2.0', method: 'notifications/initialized' },
116
+ ]);
117
+ });
118
+
119
+ test('a failed backend closes the proxy and resolves pending requests without host EOF', async () => {
120
+ const crashing = path.join(scratch, 'crashing-engine');
121
+ fs.writeFileSync(crashing, '#!/usr/bin/env node\nprocess.stdin.once("data", () => process.exit(2));\n');
122
+ fs.chmodSync(crashing, 0o755);
123
+ const script = `const {runSafeMcp}=require(${JSON.stringify(path.join(pkg, 'src/safe-mcp.js'))});runSafeMcp({resolved:{path:${JSON.stringify(crashing)},verified:true,sha256:${JSON.stringify(cls.engineSha256)}}});`;
124
+ const child = spawn(process.execPath, ['-e', script], { stdio: ['pipe', 'pipe', 'pipe'] });
125
+ let output = '';
126
+ child.stdout.on('data', (chunk) => { output += chunk; });
127
+ child.stdin.write(msg(1, 'tools/list') + '\n');
128
+ const timer = setTimeout(() => child.kill(), 2000);
129
+ const code = await new Promise((resolve) => child.on('exit', resolve));
130
+ clearTimeout(timer);
131
+ assert.equal(code, 1);
132
+ assert.match(output, /Cloud.ru engine connection closed/);
133
+ });
134
+
135
+ test('safe proxy rejects malformed JSON and batch requests', () => {
136
+ const out = run(['not-json', JSON.stringify([{ jsonrpc: '2.0', id: 1, method: 'tools/list' }])]);
137
+ assert.equal(out.responses.length, 2);
138
+ assert.ok(out.responses.every((r) => r.error));
139
+ assert.equal(out.forwarded.length, 0);
140
+ });
141
+
142
+ test('production command refuses an engine with a different hash', () => {
143
+ const r = spawnSync(process.execPath, [path.join(pkg, 'bin/cloudru-hub.js'), 'mcp-safe'], {
144
+ input: msg(1, 'tools/list') + '\n', encoding: 'utf8', timeout: 5000,
145
+ env: { ...process.env, CLOUDRU_VM_BIN: fake },
146
+ });
147
+ assert.equal(r.status, 1);
148
+ assert.match(r.stderr, /pinned engine SHA-256 required/);
149
+ assert.equal(r.stdout, '');
150
+ });
151
+
152
+ test('named toolsets reduce the visible surface and each member is allow-classified', () => {
153
+ for (const group of Object.keys(GROUPS)) {
154
+ const names = selectedToolNames(group);
155
+ assert.ok(names.length > 0);
156
+ for (const name of names) assert.equal(cls.tools[name].permission, 'allow');
157
+ }
158
+ const out = run([msg(1, 'tools/list'), msg(2, 'tools/call', { name: 'deploy', arguments: { confirmed: true } })], 'inventory');
159
+ assert.deepEqual(out.responses.find((r) => r.id === 1).result.tools.map((x) => x.name), ['vm_list']);
160
+ assert.equal(out.forwarded.filter((m) => m.method === 'tools/call').length, 0);
161
+ assert.throws(() => selectedToolNames('inventory,all'), /invalid toolsets/);
162
+ });