@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.
- package/.dz-manifest.json +62 -18
- package/CHANGELOG.md +89 -0
- package/LICENSE +4 -41
- package/README.md +67 -58
- package/THIRD_PARTY_NOTICES.md +5 -4
- package/bin/cloudru-hub.js +0 -0
- package/data/tools-classification.json +9 -9
- package/data/tools-list-snapshot.json +6 -6
- package/docs/HERMES-CODEX.md +34 -0
- package/docs/LOCAL-TESTING.md +14 -22
- package/package.json +16 -10
- package/sbom.json +127 -17
- package/src/cli.js +65 -17
- package/src/credentials.js +61 -0
- package/src/deploy-approval.js +120 -0
- package/src/install.js +246 -27
- package/src/k8s-approval.js +208 -0
- package/src/probe-child.js +38 -0
- package/src/resolve.js +3 -3
- package/src/safe-mcp.js +268 -0
- package/src/toolsets.js +58 -0
- package/templates/.claude/hooks/cloudru-ssh-guard.cjs +0 -0
- package/test/billing-credentials.test.mjs +113 -0
- package/test/classification.test.mjs +23 -32
- package/test/deploy-approval.test.mjs +154 -0
- package/test/dialects.test.mjs +11 -1
- package/test/install.test.mjs +235 -11
- package/test/k8s-approval.test.mjs +125 -0
- package/test/mutation-registry.json +6 -6
- package/test/pack-holds.test.mjs +14 -16
- package/test/resolve.test.mjs +58 -9
- package/test/safe-mcp.test.mjs +162 -0
|
@@ -0,0 +1,113 @@
|
|
|
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 { spawnSync } from 'node:child_process';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const { readCredentials } = require('../src/credentials.js');
|
|
11
|
+
const classification = require('../data/tools-classification.json');
|
|
12
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'cloudru-billing-credentials-'));
|
|
13
|
+
after(() => fs.rmSync(scratch, { recursive: true, force: true }));
|
|
14
|
+
|
|
15
|
+
const credentials = path.join(scratch, 'credentials.json');
|
|
16
|
+
const engine = path.join(scratch, 'fake-engine');
|
|
17
|
+
const projectId = '11111111-2222-3333-4444-555555555555';
|
|
18
|
+
const agreementId = 'agreement-123';
|
|
19
|
+
|
|
20
|
+
fs.writeFileSync(engine, `#!/usr/bin/env node
|
|
21
|
+
process.stdin.setEncoding('utf8');
|
|
22
|
+
let buffer = '';
|
|
23
|
+
process.stdin.on('data', (chunk) => {
|
|
24
|
+
buffer += chunk;
|
|
25
|
+
let end;
|
|
26
|
+
while ((end = buffer.indexOf('\\n')) >= 0) {
|
|
27
|
+
const line = buffer.slice(0, end);
|
|
28
|
+
buffer = buffer.slice(end + 1);
|
|
29
|
+
if (!line) continue;
|
|
30
|
+
const message = JSON.parse(line);
|
|
31
|
+
const result = { content: [{ type: 'text', text: JSON.stringify({
|
|
32
|
+
agreement: process.env.CLOUDRU_AGREEMENT_ID || null,
|
|
33
|
+
keyId: process.env.CLOUDRU_KEY_ID || null,
|
|
34
|
+
projectId: process.env.CLOUDRU_PROJECT_ID || null,
|
|
35
|
+
}) }] };
|
|
36
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result }) + '\\n');
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
`, { mode: 0o755 });
|
|
40
|
+
|
|
41
|
+
function callBilling(extraEnv = {}, toolsets = 'billing') {
|
|
42
|
+
const script = `const {runSafeMcp}=require(${JSON.stringify(path.resolve(import.meta.dirname, '../src/safe-mcp.js'))});` +
|
|
43
|
+
`const result=runSafeMcp({toolsets:${JSON.stringify(toolsets)},resolved:{path:${JSON.stringify(engine)},verified:true,sha256:${JSON.stringify(classification.engineSha256)}}});if(result!==null)process.exitCode=result;`;
|
|
44
|
+
const input = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'billing_balance', arguments: {} } }) + '\n';
|
|
45
|
+
return spawnSync(process.execPath, ['-e', script], {
|
|
46
|
+
input, encoding: 'utf8', timeout: 5000,
|
|
47
|
+
env: { ...process.env, CLOUDRU_HUB_CREDENTIALS: credentials, ...extraEnv },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('billing profile forwards agreement ID and auth from owner-only credentials to the Go child', () => {
|
|
52
|
+
fs.writeFileSync(credentials, JSON.stringify({
|
|
53
|
+
key_id: 'synthetic-key', secret: 'synthetic-secret', project_id: projectId,
|
|
54
|
+
agreement_id: agreementId,
|
|
55
|
+
}), { mode: 0o600 });
|
|
56
|
+
const run = callBilling({ CLOUDRU_AGREEMENT_ID: 'stale-env-agreement' });
|
|
57
|
+
assert.equal(run.status, 0, run.stderr);
|
|
58
|
+
const response = JSON.parse(run.stdout.trim());
|
|
59
|
+
assert.deepEqual(JSON.parse(response.result.content[0].text), {
|
|
60
|
+
agreement: agreementId, keyId: 'synthetic-key', projectId,
|
|
61
|
+
});
|
|
62
|
+
assert.doesNotMatch(run.stdout + run.stderr, /synthetic-secret/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('agreement ID is optional in the file and can still come from the environment', () => {
|
|
66
|
+
fs.writeFileSync(credentials, JSON.stringify({
|
|
67
|
+
key_id: 'synthetic-key', secret: 'synthetic-secret', project_id: projectId,
|
|
68
|
+
}), { mode: 0o600 });
|
|
69
|
+
const run = callBilling({ CLOUDRU_AGREEMENT_ID: agreementId });
|
|
70
|
+
assert.equal(run.status, 0, run.stderr);
|
|
71
|
+
assert.equal(JSON.parse(JSON.parse(run.stdout.trim()).result.content[0].text).agreement, agreementId);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('mixed and all profiles also forward the agreement ID when billing is selected', () => {
|
|
75
|
+
fs.writeFileSync(credentials, JSON.stringify({
|
|
76
|
+
key_id: 'synthetic-key', secret: 'synthetic-secret', project_id: projectId,
|
|
77
|
+
agreement_id: agreementId,
|
|
78
|
+
}), { mode: 0o600 });
|
|
79
|
+
for (const profile of ['inventory,billing', 'all']) {
|
|
80
|
+
const run = callBilling({}, profile);
|
|
81
|
+
assert.equal(run.status, 0, `${profile}: ${run.stderr}`);
|
|
82
|
+
assert.equal(JSON.parse(JSON.parse(run.stdout.trim()).result.content[0].text).agreement, agreementId);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('billing keeps an environment-only setup when no credential file exists', () => {
|
|
87
|
+
const run = callBilling({
|
|
88
|
+
HOME: path.join(scratch, 'empty-home'),
|
|
89
|
+
CLOUDRU_HUB_CREDENTIALS: '',
|
|
90
|
+
CLOUDRU_AGREEMENT_ID: agreementId,
|
|
91
|
+
CLOUDRU_KEY_ID: 'environment-key',
|
|
92
|
+
CLOUDRU_PROJECT_ID: projectId,
|
|
93
|
+
});
|
|
94
|
+
assert.equal(run.status, 0, run.stderr);
|
|
95
|
+
assert.deepEqual(JSON.parse(JSON.parse(run.stdout.trim()).result.content[0].text), {
|
|
96
|
+
agreement: agreementId, keyId: 'environment-key', projectId,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('explicit missing or malformed billing credential files fail closed', () => {
|
|
101
|
+
const missing = callBilling({ CLOUDRU_HUB_CREDENTIALS: path.join(scratch, 'missing.json') });
|
|
102
|
+
assert.equal(missing.status, 1);
|
|
103
|
+
assert.match(missing.stderr, /cloudru-hub mcp-safe:/);
|
|
104
|
+
fs.writeFileSync(credentials, JSON.stringify({
|
|
105
|
+
key_id: 'synthetic-key', secret: 'synthetic-secret', project_id: projectId,
|
|
106
|
+
agreement_id: 'bad\nvalue',
|
|
107
|
+
}), { mode: 0o600 });
|
|
108
|
+
const malformed = callBilling();
|
|
109
|
+
assert.equal(malformed.status, 1);
|
|
110
|
+
assert.match(malformed.stderr, /agreement_id/);
|
|
111
|
+
assert.equal(malformed.stdout, '');
|
|
112
|
+
assert.throws(() => readCredentials({ CLOUDRU_HUB_CREDENTIALS: credentials }), /agreement_id/);
|
|
113
|
+
});
|
|
@@ -19,34 +19,28 @@ test('every snapshot tool is classified, and nothing extra', () => {
|
|
|
19
19
|
assert.equal(snapNames.length, 144);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
test('counts are the reviewed golden split:
|
|
23
|
-
assert.deepEqual(cls.counts, { ask:
|
|
22
|
+
test('counts are the reviewed golden split: 64 ask / 2 deny / 78 allow', () => {
|
|
23
|
+
assert.deepEqual(cls.counts, { ask: 64, deny: 2, allow: 78 });
|
|
24
24
|
const by = namesByPermission(cls);
|
|
25
|
-
assert.equal(by.ask.length,
|
|
25
|
+
assert.equal(by.ask.length, 64);
|
|
26
26
|
assert.equal(by.deny.length, 2);
|
|
27
|
-
assert.equal(by.allow.length,
|
|
27
|
+
assert.equal(by.allow.length, 78);
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
// attempted `provision` (stopped only at AUTH_REQUIRED). If this ever flips back to
|
|
36
|
-
// allow, the fully-frictionless chain stack_plan→stack_status provisions a billable
|
|
37
|
-
// stack with ZERO permission prompts — defeating the ADR-004 brake.
|
|
38
|
-
test('stack_status is ASK — it advances jobs and invokes paid create tools (tools_stack.go:479→:220)', () => {
|
|
39
|
-
assert.equal(cls.tools.stack_status.permission, 'ask');
|
|
40
|
-
assert.equal(cls.tools.stack_status.effect, 'mutating');
|
|
30
|
+
// The imported engine now makes status observational. The Go regression test
|
|
31
|
+
// proves repeated reads do not advance a planned/running job; only apply can.
|
|
32
|
+
test('stack_status is ALLOW only after the engine stopped advancing jobs', () => {
|
|
33
|
+
assert.equal(cls.tools.stack_status.permission, 'allow');
|
|
34
|
+
assert.equal(cls.tools.stack_status.effect, 'read-only');
|
|
41
35
|
const rules = permissionRules(cls);
|
|
42
|
-
assert.ok(rules.
|
|
43
|
-
assert.ok(rules.
|
|
44
|
-
assert.ok(!rules.
|
|
36
|
+
assert.ok(rules.allow.includes('mcp__cloudru-vm__stack_status'));
|
|
37
|
+
assert.ok(rules.allow.includes('mcp__cloudru_vm__stack_status'));
|
|
38
|
+
assert.ok(!rules.ask.some((r) => r.endsWith('__stack_status')));
|
|
45
39
|
});
|
|
46
40
|
|
|
47
|
-
test('the stack chain:
|
|
48
|
-
assert.equal(cls.tools.
|
|
49
|
-
for (const name of ['stack_apply', '
|
|
41
|
+
test('the stack chain: plan and status are observational; apply and destroy require approval', () => {
|
|
42
|
+
for (const name of ['stack_plan', 'stack_status']) assert.equal(cls.tools[name].permission, 'allow', name);
|
|
43
|
+
for (const name of ['stack_apply', 'stack_destroy']) {
|
|
50
44
|
assert.equal(cls.tools[name].permission, 'ask', name);
|
|
51
45
|
}
|
|
52
46
|
});
|
|
@@ -54,12 +48,9 @@ test('the stack chain: only the pure plan is allow — every advancing/applying/
|
|
|
54
48
|
// --- Invariant of the 2026-08-10 shell-reach re-audit (AM-2, cross-model QE HIGH) ---
|
|
55
49
|
// The classification method has a SECOND hazard axis: a tool is allow ONLY if it does NOT
|
|
56
50
|
// pass a user-controlled arg into a remote shell. `logs` does — handleLogs (tools.go:1758)
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
// user's VM. If this flips back to allow the injection sits behind a SILENT allow — a
|
|
61
|
-
// zero-prompt command-injection path defeating the ADR-004 brake.
|
|
62
|
-
test('logs is ASK — it concatenates a user-controlled service arg into a remote shell (logs.go:61 → ssh.go:117)', () => {
|
|
51
|
+
// reaches a remote shell. The imported engine quotes that argument, but a log
|
|
52
|
+
// read can disclose sensitive output and still crosses the shell-reach axis.
|
|
53
|
+
test('logs remains ASK because it reaches remote SSH/compose', () => {
|
|
63
54
|
assert.equal(cls.tools.logs.permission, 'ask');
|
|
64
55
|
const rules = permissionRules(cls);
|
|
65
56
|
assert.ok(rules.ask.includes('mcp__cloudru-vm__logs'));
|
|
@@ -70,10 +61,10 @@ test('logs is ASK — it concatenates a user-controlled service arg into a remot
|
|
|
70
61
|
// The method doc (the $comment) must keep documenting BOTH hazard axes — losing the
|
|
71
62
|
// shell-reach axis silently is the exact regression that let `logs` sit in allow. This is
|
|
72
63
|
// a layer-1 guard on the METHOD, not just the instance.
|
|
73
|
-
test('the classification method doc documents the shell-reach
|
|
64
|
+
test('the classification method doc documents the shell-reach axis and the engine fix', () => {
|
|
74
65
|
assert.match(cls.$comment, /shell-reach/i, 'method doc must name the shell-reach hazard axis');
|
|
75
|
-
assert.match(cls.$comment, /user-controlled argument
|
|
76
|
-
assert.match(cls.$comment, /shell-
|
|
66
|
+
assert.match(cls.$comment, /user-controlled argument reaching shell/i);
|
|
67
|
+
assert.match(cls.$comment, /logs shell-quotes service/i);
|
|
77
68
|
});
|
|
78
69
|
|
|
79
70
|
// No allow tool may reach a shell: every allow entry must be free of the shell-reach
|
|
@@ -128,9 +119,9 @@ test('read-only recon stays frictionless: vm_list, billing_*, deploy_plan are al
|
|
|
128
119
|
test('permissionRules expands every name in BOTH prefix spellings (unverified normalization, deny-by-default)', () => {
|
|
129
120
|
const rules = permissionRules(cls);
|
|
130
121
|
assert.equal(SERVER_PREFIXES.length, 2);
|
|
131
|
-
assert.equal(rules.ask.length,
|
|
122
|
+
assert.equal(rules.ask.length, 64 * 2);
|
|
132
123
|
assert.equal(rules.deny.length, 2 * 2);
|
|
133
|
-
assert.equal(rules.allow.length,
|
|
124
|
+
assert.equal(rules.allow.length, 78 * 2);
|
|
134
125
|
assert.ok(rules.ask.includes('mcp__cloudru-vm__deploy'));
|
|
135
126
|
assert.ok(rules.ask.includes('mcp__cloudru_vm__deploy'));
|
|
136
127
|
assert.ok(rules.deny.includes('mcp__cloudru-vm__secret_value'));
|
|
@@ -0,0 +1,154 @@
|
|
|
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 { spawnSync } from 'node:child_process';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const { readCredentials } = require('../src/credentials.js');
|
|
11
|
+
const { issueApproval } = require('../src/deploy-approval.js');
|
|
12
|
+
const cls = require('../data/tools-classification.json');
|
|
13
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'cloudru-deploy-guard-'));
|
|
14
|
+
after(() => fs.rmSync(scratch, { recursive: true, force: true }));
|
|
15
|
+
const projectId = '11111111-2222-3333-4444-555555555555';
|
|
16
|
+
const vmId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
|
|
17
|
+
const otherVM = 'bbbbbbbb-cccc-dddd-eeee-ffffffffffff';
|
|
18
|
+
const planId = 'plan-123456abcdef';
|
|
19
|
+
const file = path.join(scratch, 'credentials.json');
|
|
20
|
+
const planFile = path.join(scratch, '.cloudru-vm', 'plans', `${planId}.json`);
|
|
21
|
+
const log = path.join(scratch, 'forwarded.jsonl');
|
|
22
|
+
const fake = path.join(scratch, 'fake-engine');
|
|
23
|
+
const env = { ...process.env, CLOUDRU_HUB_CREDENTIALS: file, CLOUDRU_HUB_APPROVALS: path.join(scratch, 'approvals'), CLOUDRU_VM_HOME: scratch, FAKE_LOG: log };
|
|
24
|
+
fs.writeFileSync(file, JSON.stringify({ key_id: 'test-key', secret: 'test-secret', project_id: projectId, region: 'ru.AZ-2' }), { mode: 0o600 });
|
|
25
|
+
fs.mkdirSync(path.dirname(planFile), { recursive: true });
|
|
26
|
+
const plan = { plan_id: planId, project_id: projectId, expires_at: new Date(Date.now() + 1800000).toISOString(), vm: { name: 'snake-pilot' }, ssh: { login: 'user1' }, cost: { currency: 'RUB', total_monthly_rub: 42 } };
|
|
27
|
+
const savePlan = () => fs.writeFileSync(planFile, JSON.stringify(plan));
|
|
28
|
+
savePlan();
|
|
29
|
+
fs.writeFileSync(fake, `#!/usr/bin/env node
|
|
30
|
+
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==='tools/list')result={tools:[{name:'deploy_apply'},{name:'vm_exec'},{name:'destroy'}]};else if(m.method==='tools/call'){const deployment=process.env.FAKE_DEPLOY_OUTCOME?JSON.parse(process.env.FAKE_DEPLOY_OUTCOME):{vm_id:'${vmId}',status:'running',public_ip:'203.0.113.10',password:'generated-secret'};result={isError:m.params.name==='deploy_apply'&&!!process.env.FAKE_DEPLOY_ERROR,content:[{type:'text',text:m.params.name==='deploy_apply'?JSON.stringify(deployment):JSON.stringify({status:'ok'})}]};}process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:m.id,result})+'\\n');}});
|
|
31
|
+
`);
|
|
32
|
+
fs.chmodSync(fake, 0o755);
|
|
33
|
+
const rpc = (id, name, args = {}) => JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } });
|
|
34
|
+
function run(lines, overrides = {}) {
|
|
35
|
+
fs.writeFileSync(log, '');
|
|
36
|
+
const script = `const {runSafeMcp}=require(${JSON.stringify(path.resolve(import.meta.dirname, '../src/safe-mcp.js'))});runSafeMcp({toolsets:'vm-deploy',resolved:{path:${JSON.stringify(fake)},verified:true,sha256:${JSON.stringify(cls.engineSha256)}}});`;
|
|
37
|
+
const r = spawnSync(process.execPath, ['-e', script], { input: lines.join('\n') + '\n', encoding: 'utf8', timeout: 5000, env: { ...env, ...overrides } });
|
|
38
|
+
assert.equal(r.status, 0, r.stderr || r.error?.message);
|
|
39
|
+
return { responses: r.stdout.trim().split('\n').map(JSON.parse), forwarded: fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('credentials require owner-only regular file and do not interpolate shell text', () => {
|
|
43
|
+
assert.equal(readCredentials(env).projectId, projectId);
|
|
44
|
+
assert.equal(readCredentials(env).env.CLOUDRU_KEY_ID, 'test-key');
|
|
45
|
+
fs.chmodSync(file, 0o644);
|
|
46
|
+
assert.throws(() => readCredentials(env), /0600/);
|
|
47
|
+
fs.chmodSync(file, 0o600);
|
|
48
|
+
fs.writeFileSync(file, '{"secret":THIS_IS_A_FAKE_SECRET_FOR_QE}', { mode: 0o600 });
|
|
49
|
+
assert.throws(() => readCredentials(env), (error) => error.message === 'credentials file is not valid JSON');
|
|
50
|
+
fs.writeFileSync(file, JSON.stringify({ key_id: 'test-key', secret: 'test-secret', project_id: projectId, region: 'ru.AZ-2' }), { mode: 0o600 });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('cost cap and project must match the persisted plan before approval', () => {
|
|
54
|
+
assert.throws(() => issueApproval(planId, 41, env), /cost exceeds/);
|
|
55
|
+
plan.project_id = otherVM;
|
|
56
|
+
savePlan();
|
|
57
|
+
assert.throws(() => issueApproval(planId, 100, env), /project differs/);
|
|
58
|
+
plan.project_id = projectId;
|
|
59
|
+
savePlan();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('approved plan is single-use; remote commands are confined to its VM', () => {
|
|
63
|
+
issueApproval(planId, 50, env);
|
|
64
|
+
const out = run([
|
|
65
|
+
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
|
|
66
|
+
rpc(2, 'deploy_apply', { plan_id: planId, confirmed: false }),
|
|
67
|
+
rpc(3, 'deploy_apply', { plan_id: planId, confirmed: true }),
|
|
68
|
+
]);
|
|
69
|
+
const followup = run([
|
|
70
|
+
rpc(4, 'vm_exec', { vm: otherVM, command: 'id' }),
|
|
71
|
+
rpc(5, 'vm_exec', { vm: vmId, user: 'root', command: 'whoami' }),
|
|
72
|
+
rpc(6, 'destroy', { confirmed: true }),
|
|
73
|
+
]);
|
|
74
|
+
const by = Object.fromEntries([...out.responses, ...followup.responses].map((x) => [x.id, x]));
|
|
75
|
+
assert.deepEqual(by[1].result.tools.map((x) => x.name), ['deploy_apply', 'vm_exec']);
|
|
76
|
+
assert.equal(by[2].result.isError, false);
|
|
77
|
+
assert.doesNotMatch(JSON.stringify(by[2]), /generated-secret/);
|
|
78
|
+
for (const id of [3, 4, 6]) assert.ok(by[id].error, `request ${id} blocked`);
|
|
79
|
+
assert.equal(by[5].result.content[0].text, JSON.stringify({ status: 'ok' }));
|
|
80
|
+
const calls = [...out.forwarded, ...followup.forwarded].filter((x) => x.method === 'tools/call');
|
|
81
|
+
assert.deepEqual(calls.map((x) => x.params), [
|
|
82
|
+
{ name: 'deploy_apply', arguments: { plan_id: planId, confirmed: true } },
|
|
83
|
+
{ name: 'vm_exec', arguments: { vm: vmId, user: 'user1', command: 'whoami' } },
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('a changed plan invalidates the exact approval', () => {
|
|
88
|
+
const second = 'plan-abcdef123456';
|
|
89
|
+
const secondFile = path.join(path.dirname(planFile), `${second}.json`);
|
|
90
|
+
fs.writeFileSync(secondFile, JSON.stringify({ ...plan, plan_id: second }));
|
|
91
|
+
issueApproval(second, 50, env);
|
|
92
|
+
fs.writeFileSync(secondFile, JSON.stringify({ ...plan, plan_id: second, cost: { currency: 'RUB', total_monthly_rub: 49 } }));
|
|
93
|
+
const out = run([rpc(1, 'deploy_apply', { plan_id: second, confirmed: true })]);
|
|
94
|
+
assert.match(out.responses[0].error.message, /approval does not match/);
|
|
95
|
+
assert.equal(out.forwarded.length, 0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('partial deploy preserves its VM UUID for recovery and redacts nested secrets', () => {
|
|
99
|
+
const third = 'plan-123456fedcba';
|
|
100
|
+
fs.writeFileSync(path.join(path.dirname(planFile), `${third}.json`), JSON.stringify({ ...plan, plan_id: third }));
|
|
101
|
+
issueApproval(third, 50, env);
|
|
102
|
+
const outcome = { status: 'state_persistence_failed', result: { vm_id: otherVM, status: 'running', password: 'generated-secret' }, error: 'local state write failed' };
|
|
103
|
+
const out = run([rpc(1, 'deploy_apply', { plan_id: third })], { FAKE_DEPLOY_OUTCOME: JSON.stringify(outcome), FAKE_DEPLOY_ERROR: '1' });
|
|
104
|
+
assert.equal(out.responses[0].result.isError, true);
|
|
105
|
+
const returned = JSON.parse(out.responses[0].result.content[0].text);
|
|
106
|
+
assert.equal(returned.result.vm_id, otherVM);
|
|
107
|
+
assert.equal(returned.result.password, undefined);
|
|
108
|
+
assert.match(returned.recovery, /Inspect/);
|
|
109
|
+
const denied = run([rpc(2, 'vm_exec', { vm: otherVM, command: 'id' })]);
|
|
110
|
+
assert.ok(denied.responses[0].error);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('static site requires the reviewed digest and never grants an SSH command session', () => {
|
|
114
|
+
const id = 'plan-aabbccddeeff';
|
|
115
|
+
const digest = 'a'.repeat(64);
|
|
116
|
+
const sitePlan = { ...plan, plan_id: id, ports: [80], network: { floating_ip: true, subnet_id: 'subnet-a', attach_fip_id: otherVM },
|
|
117
|
+
static_site: { source: 'fixtures/snake/index.html', sha256: digest, size_bytes: 12, source_base64: Buffer.from('hello snake!').toString('base64'),
|
|
118
|
+
files: [{ path: 'index.html', sha256: digest, size_bytes: 12, content_base64: Buffer.from('hello snake!').toString('base64') }] } };
|
|
119
|
+
const siteFile = path.join(path.dirname(planFile), `${id}.json`);
|
|
120
|
+
fs.writeFileSync(siteFile, JSON.stringify(sitePlan));
|
|
121
|
+
assert.throws(() => issueApproval(id, 50, env), /site-sha256/);
|
|
122
|
+
assert.throws(() => issueApproval(id, 50, env, Date.now(), 'b'.repeat(64)), /site-sha256/);
|
|
123
|
+
const approved = issueApproval(id, 50, env, Date.now(), digest);
|
|
124
|
+
assert.equal(approved.staticSite.sha256, digest);
|
|
125
|
+
assert.match(approved.staticSite.ingress, /dedicated security group/);
|
|
126
|
+
assert.match(approved.staticSite.estimateScope, /public IP charges excluded/);
|
|
127
|
+
const outcome = { vm_id: otherVM, status: 'running', public_ip: '203.0.113.10', http_verification: { verified: true } };
|
|
128
|
+
const out = run([rpc(1, 'deploy_apply', { plan_id: id })], { FAKE_DEPLOY_OUTCOME: JSON.stringify(outcome) });
|
|
129
|
+
assert.equal(out.responses[0].result.isError, false);
|
|
130
|
+
const denied = run([rpc(2, 'vm_exec', { vm: otherVM, command: 'id' })]);
|
|
131
|
+
assert.ok(denied.responses[0].error);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('static-site artifact and same-price network changes invalidate approval', () => {
|
|
135
|
+
const site = { source: 'snake.zip', sha256: 'c'.repeat(64), size_bytes: 20, source_base64: Buffer.from('test').toString('base64'),
|
|
136
|
+
files: [{ path: 'index.html', sha256: 'd'.repeat(64), size_bytes: 10, content_base64: 'aGVsbG8=' }] };
|
|
137
|
+
for (const [suffix, mutation] of [
|
|
138
|
+
['010101010101', (p) => { p.static_site.sha256 = 'e'.repeat(64); }],
|
|
139
|
+
['020202020202', (p) => { p.network.subnet_id = 'subnet-b'; }],
|
|
140
|
+
['030303030303', (p) => { p.vm.zone = { id: 'zone-b', name: 'AZ-2' }; }],
|
|
141
|
+
]) {
|
|
142
|
+
const id = `plan-${suffix}`;
|
|
143
|
+
const original = { ...plan, plan_id: id, ports: [80], network: { floating_ip: true, subnet_id: 'subnet-a', attach_fip_id: otherVM },
|
|
144
|
+
vm: { ...plan.vm, zone: { id: 'zone-a', name: 'AZ-1' } }, static_site: structuredClone(site) };
|
|
145
|
+
const filename = path.join(path.dirname(planFile), `${id}.json`);
|
|
146
|
+
fs.writeFileSync(filename, JSON.stringify(original));
|
|
147
|
+
issueApproval(id, 50, env, Date.now(), site.sha256);
|
|
148
|
+
mutation(original);
|
|
149
|
+
fs.writeFileSync(filename, JSON.stringify(original));
|
|
150
|
+
const out = run([rpc(1, 'deploy_apply', { plan_id: id })]);
|
|
151
|
+
assert.match(out.responses[0].error.message, /approval does not match/);
|
|
152
|
+
assert.equal(out.forwarded.length, 0);
|
|
153
|
+
}
|
|
154
|
+
});
|
package/test/dialects.test.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Skill dialect compiler tests — ADR-006 Confirmation, all layer 1: size, links, dialect
|
|
2
2
|
// grep, determinism. Uses a SYNTHETIC fixture (our own text carrying the Hermes-dialect
|
|
3
3
|
// tokens) — the real canonical corpus is upstream content and never ships in this package.
|
|
4
|
-
import { test } from 'node:test';
|
|
4
|
+
import { after, test } from 'node:test';
|
|
5
5
|
import assert from 'node:assert/strict';
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import os from 'node:os';
|
|
@@ -11,8 +11,18 @@ import { createRequire } from 'node:module';
|
|
|
11
11
|
const require = createRequire(import.meta.url);
|
|
12
12
|
const { compileSkill, loadDialects, filterFrontmatter } = require('../src/dialects.js');
|
|
13
13
|
|
|
14
|
+
// Every scratch dir is removed at the end (after() + process exit) unless DZ_KEEP_TMP=1 —
|
|
15
|
+
// MEASURED 2026-09-20: 81 cloudru-dialects-* dirs per full sweep (backlog 7cb6f9b7).
|
|
16
|
+
const created = [];
|
|
17
|
+
const removeCreated = () => {
|
|
18
|
+
if (process.env.DZ_KEEP_TMP === '1') return;
|
|
19
|
+
for (const d of created.splice(0)) fs.rmSync(d, { recursive: true, force: true });
|
|
20
|
+
};
|
|
21
|
+
after(removeCreated);
|
|
22
|
+
process.on('exit', removeCreated);
|
|
14
23
|
function fixture() {
|
|
15
24
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cloudru-dialects-'));
|
|
25
|
+
created.push(dir);
|
|
16
26
|
fs.writeFileSync(path.join(dir, 'SKILL.md'), `---
|
|
17
27
|
name: cloudru-hub
|
|
18
28
|
description: Управление Cloud.ru Evolution.
|