@dzhechkov/cloudru-hub 0.1.12 → 0.1.13

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.
@@ -1,17 +1,28 @@
1
1
  // Engine resolution tests — ADR-002: runtime resolution, sha256 pin, loud esbuild-style error.
2
- import { test } from 'node:test';
2
+ import { after, test } from 'node:test';
3
3
  import assert from 'node:assert/strict';
4
4
  import fs from 'node:fs';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
7
  import crypto from 'node:crypto';
8
+ import { spawnSync } from 'node:child_process';
8
9
  import { createRequire } from 'node:module';
10
+ import { fileURLToPath } from 'node:url';
9
11
 
10
12
  const require = createRequire(import.meta.url);
11
13
  const { resolveEngine, sha256File, platformKey } = require('../src/resolve.js');
12
14
  const PKG = require('../package.json');
13
15
 
14
- const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'cloudru-resolve-'));
16
+ // Every scratch dir is removed at the end (after() + process exit) unless DZ_KEEP_TMP=1 —
17
+ // MEASURED 2026-09-20: 18 cloudru-resolve-* dirs per full sweep (backlog 7cb6f9b7).
18
+ const created = [];
19
+ const removeCreated = () => {
20
+ if (process.env.DZ_KEEP_TMP === '1') return;
21
+ for (const d of created.splice(0)) fs.rmSync(d, { recursive: true, force: true });
22
+ };
23
+ after(removeCreated);
24
+ process.on('exit', removeCreated);
25
+ const tmp = () => { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'cloudru-resolve-')); created.push(d); return d; };
15
26
 
16
27
  test('env CLOUDRU_VM_BIN wins and is hashed; a non-pinned file reports verified:false but resolves', () => {
17
28
  const dir = tmp();
@@ -24,6 +35,37 @@ test('env CLOUDRU_VM_BIN wins and is hashed; a non-pinned file reports verified:
24
35
  assert.equal(r.verified, false);
25
36
  });
26
37
 
38
+ test('mcp refuses a non-pinned engine before executing it', () => {
39
+ const dir = tmp();
40
+ const marker = path.join(dir, 'executed');
41
+ const fake = path.join(dir, 'cloudru-vm');
42
+ fs.writeFileSync(fake, `#!/bin/sh\ntouch "${marker}"\n`);
43
+ fs.chmodSync(fake, 0o755);
44
+ const result = spawnSync(process.execPath, [path.join(path.dirname(fileURLToPath(import.meta.url)), '../bin/cloudru-hub.js'), 'mcp'], {
45
+ encoding: 'utf8', timeout: 5000,
46
+ env: { ...process.env, CLOUDRU_VM_BIN: fake, CLOUDRU_HUB_CONFIG: path.join(dir, 'none.json') },
47
+ });
48
+ assert.equal(result.status, 1);
49
+ assert.match(result.stderr, /does not match pinned .*refusing to launch/);
50
+ assert.equal(fs.existsSync(marker), false);
51
+ });
52
+
53
+ test('self-test stops on a non-pinned engine before version or MCP probe', () => {
54
+ const dir = tmp();
55
+ const marker = path.join(dir, 'executed');
56
+ const fake = path.join(dir, 'cloudru-vm');
57
+ fs.writeFileSync(fake, `#!/bin/sh\ntouch "${marker}"\n`);
58
+ fs.chmodSync(fake, 0o755);
59
+ const result = spawnSync(process.execPath, [path.join(path.dirname(fileURLToPath(import.meta.url)), '../bin/cloudru-hub.js'), 'self-test', '--json'], {
60
+ encoding: 'utf8', timeout: 5000,
61
+ env: { ...process.env, CLOUDRU_VM_BIN: fake, CLOUDRU_HUB_CONFIG: path.join(dir, 'none.json') },
62
+ });
63
+ assert.equal(result.status, 1);
64
+ const report = JSON.parse(result.stdout);
65
+ assert.deepEqual(report.steps.map((step) => [step.name, step.ok]), [['resolve', true], ['sha256', false]]);
66
+ assert.equal(fs.existsSync(marker), false);
67
+ });
68
+
27
69
  test('a file matching the pinned baseline hash reports verified:true', () => {
28
70
  // Only meaningful on linux-x64 (the single pinned platform, ADR-002 point 4).
29
71
  const key = platformKey();
@@ -73,9 +115,9 @@ test('sha256File matches node crypto', () => {
73
115
  assert.equal(sha256File(f), crypto.createHash('sha256').update('abc').digest('hex'));
74
116
  });
75
117
 
76
- test('the pinned linux-x64 hash is the ADR-002 measured engine sha256', () => {
118
+ test('the pinned linux-x64 hash is the rebuilt private engine sha256', () => {
77
119
  assert.equal(
78
120
  PKG.cloudruHub.binaryHashes['linux-x64'],
79
- '59f83fc0678b95146ca539764d0c805bd2514b8588f198c1e835b26e54c05124',
121
+ '89c5073418927ee71ac1de381eaba5ab69d599d615edc51b868d876e712dbf10',
80
122
  );
81
123
  });
@@ -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
+ });