@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,61 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const UUID = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i;
|
|
7
|
+
|
|
8
|
+
const ZONES = Object.freeze({
|
|
9
|
+
'ru.AZ-1': '7c99a597-8516-494f-a2c7-d7377048681e',
|
|
10
|
+
'ru.AZ-2': '479a4ab3-3ff3-4972-95c5-7610bac5c0bb',
|
|
11
|
+
'ru.AZ-3': '2c63c482-2532-4bba-8c9b-70ea330507bf',
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
function credentialsPath(env = process.env) {
|
|
15
|
+
return env.CLOUDRU_HUB_CREDENTIALS || path.join(os.homedir(), '.config', 'cloudru-hub', 'credentials.json');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readCredentials(env = process.env) {
|
|
19
|
+
const file = credentialsPath(env);
|
|
20
|
+
const st = fs.lstatSync(file);
|
|
21
|
+
if (!st.isFile() || st.isSymbolicLink()) throw new Error('credentials must be a regular file');
|
|
22
|
+
if ((st.mode & 0o077) !== 0) throw new Error('credentials permissions must be 0600');
|
|
23
|
+
if (typeof process.getuid === 'function' && st.uid !== process.getuid()) throw new Error('credentials owner differs from MCP process');
|
|
24
|
+
if (st.size > 65536) throw new Error('credentials file is too large');
|
|
25
|
+
let parsed;
|
|
26
|
+
try { parsed = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
27
|
+
catch { throw new Error('credentials file is not valid JSON'); }
|
|
28
|
+
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') throw new Error('credentials JSON must be an object');
|
|
29
|
+
for (const key of ['key_id', 'secret', 'project_id']) {
|
|
30
|
+
if (typeof parsed[key] !== 'string' || !parsed[key].trim()) throw new Error(`credentials ${key} is required`);
|
|
31
|
+
if (/[\r\n\0]/.test(parsed[key])) throw new Error(`credentials ${key} contains a control character`);
|
|
32
|
+
}
|
|
33
|
+
if (parsed.agreement_id !== undefined &&
|
|
34
|
+
(typeof parsed.agreement_id !== 'string' || !parsed.agreement_id.trim() || /[\r\n\0]/.test(parsed.agreement_id))) {
|
|
35
|
+
throw new Error('credentials agreement_id must be a non-empty string without control characters');
|
|
36
|
+
}
|
|
37
|
+
if (!UUID.test(parsed.project_id)) throw new Error('credentials project_id must be a UUID');
|
|
38
|
+
const region = parsed.region || 'ru.AZ-2';
|
|
39
|
+
const zone = ZONES[region] || region;
|
|
40
|
+
if (!UUID.test(zone)) throw new Error('credentials region must be a known zone or UUID');
|
|
41
|
+
return {
|
|
42
|
+
path: file,
|
|
43
|
+
projectId: parsed.project_id,
|
|
44
|
+
env: {
|
|
45
|
+
CLOUDRU_KEY_ID: parsed.key_id,
|
|
46
|
+
CLOUDRU_SECRET: parsed.secret,
|
|
47
|
+
CLOUDRU_PROJECT_ID: parsed.project_id,
|
|
48
|
+
CLOUDRU_REGION: zone,
|
|
49
|
+
...(parsed.agreement_id === undefined ? {} : { CLOUDRU_AGREEMENT_ID: parsed.agreement_id.trim() }),
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readOptionalCredentials(env = process.env) {
|
|
55
|
+
// An explicit path must fail loudly when unreadable. Without one, retain
|
|
56
|
+
// environment-only billing setups that have no default credential file.
|
|
57
|
+
if (!env.CLOUDRU_HUB_CREDENTIALS && !fs.existsSync(credentialsPath(env))) return null;
|
|
58
|
+
return readCredentials(env);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { credentialsPath, readCredentials, readOptionalCredentials, ZONES, UUID };
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
const { readCredentials, UUID } = require('./credentials');
|
|
8
|
+
|
|
9
|
+
const PLAN_ID = /^plan-[0-9a-f]{12}$/;
|
|
10
|
+
|
|
11
|
+
function stateRoot(env = process.env) { return env.CLOUDRU_VM_HOME || os.homedir(); }
|
|
12
|
+
function approvalRoot(env = process.env) { return env.CLOUDRU_HUB_APPROVALS || path.join(os.homedir(), '.config', 'cloudru-hub', 'approvals'); }
|
|
13
|
+
function planPath(id, env = process.env) {
|
|
14
|
+
if (!PLAN_ID.test(id)) throw new Error('invalid plan_id');
|
|
15
|
+
return path.join(stateRoot(env), '.cloudru-vm', 'plans', `${id}.json`);
|
|
16
|
+
}
|
|
17
|
+
function readPlan(id, env = process.env, now = Date.now()) {
|
|
18
|
+
const file = planPath(id, env);
|
|
19
|
+
const st = fs.lstatSync(file);
|
|
20
|
+
if (!st.isFile() || st.isSymbolicLink() || st.size > 65536) throw new Error('plan file is not a bounded regular file');
|
|
21
|
+
const raw = fs.readFileSync(file);
|
|
22
|
+
const plan = JSON.parse(raw);
|
|
23
|
+
if (plan.plan_id !== id || !UUID.test(plan.project_id || '')) throw new Error('plan identity or project is invalid');
|
|
24
|
+
const expires = Date.parse(plan.expires_at);
|
|
25
|
+
if (!Number.isFinite(expires) || expires <= now) throw new Error('plan expired');
|
|
26
|
+
const price = plan.cost && plan.cost.total_monthly_rub;
|
|
27
|
+
if (!plan.cost || plan.cost.currency !== 'RUB' || !Number.isFinite(price) || price < 0) throw new Error('plan cost is not a finite RUB estimate');
|
|
28
|
+
if (typeof plan.vm?.name !== 'string' || !plan.vm.name) throw new Error('plan VM name is invalid');
|
|
29
|
+
if (!plan.static_site && (typeof plan.ssh?.login !== 'string' || !plan.ssh.login)) throw new Error('plan SSH login is invalid');
|
|
30
|
+
if (plan.static_site) {
|
|
31
|
+
const site = plan.static_site;
|
|
32
|
+
if (!/^[0-9a-f]{64}$/.test(site.sha256 || '') || !Number.isSafeInteger(site.size_bytes) || site.size_bytes < 1 || site.size_bytes > 20 * 1024 ||
|
|
33
|
+
typeof site.source_base64 !== 'string' || site.source_base64.length > 28 * 1024 ||
|
|
34
|
+
!Array.isArray(site.files) || site.files.length < 1 || site.files.length > 32 ||
|
|
35
|
+
!Array.isArray(plan.ports) || plan.ports.length !== 1 || plan.ports[0] !== 80 || plan.network?.floating_ip !== true ||
|
|
36
|
+
typeof plan.network?.attach_fip_id !== 'string' || !UUID.test(plan.network.attach_fip_id)) {
|
|
37
|
+
throw new Error('static-site plan artifact or network scope is invalid');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { plan, hash: crypto.createHash('sha256').update(raw).digest('hex'), price, expires };
|
|
41
|
+
}
|
|
42
|
+
function approvalPath(id, env = process.env) { return path.join(approvalRoot(env), `${id}.json`); }
|
|
43
|
+
function ensurePrivateDir(dir) {
|
|
44
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
45
|
+
const st = fs.lstatSync(dir);
|
|
46
|
+
if (!st.isDirectory() || st.isSymbolicLink() || (st.mode & 0o077) !== 0) throw new Error('approval directory must be private (0700)');
|
|
47
|
+
}
|
|
48
|
+
function readPrivateJson(file) {
|
|
49
|
+
const st = fs.lstatSync(file);
|
|
50
|
+
if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o077) !== 0 || st.size > 65536 ||
|
|
51
|
+
(typeof process.getuid === 'function' && st.uid !== process.getuid())) throw new Error('approval file must be private, owned and bounded');
|
|
52
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function issueApproval(id, maxMonthlyRub, env = process.env, now = Date.now(), reviewedSiteSha256 = '') {
|
|
56
|
+
const { plan, hash, price, expires } = readPlan(id, env, now);
|
|
57
|
+
if (plan.static_site && reviewedSiteSha256 !== plan.static_site.sha256) {
|
|
58
|
+
throw new Error('static-site approval requires --site-sha256 matching the reviewed artifact');
|
|
59
|
+
}
|
|
60
|
+
const credentials = readCredentials(env);
|
|
61
|
+
if (credentials.projectId !== plan.project_id) throw new Error('plan project differs from credentials project');
|
|
62
|
+
if (!Number.isFinite(maxMonthlyRub) || maxMonthlyRub < price) throw new Error('monthly cost exceeds operator cap');
|
|
63
|
+
const root = approvalRoot(env);
|
|
64
|
+
ensurePrivateDir(root);
|
|
65
|
+
const record = {
|
|
66
|
+
planId: id,
|
|
67
|
+
planHash: hash,
|
|
68
|
+
projectId: plan.project_id,
|
|
69
|
+
vmName: plan.vm.name,
|
|
70
|
+
sshUser: plan.ssh?.login || '',
|
|
71
|
+
siteMode: !!plan.static_site,
|
|
72
|
+
maxMonthlyRub,
|
|
73
|
+
issuedAt: now,
|
|
74
|
+
expiresAt: Math.min(expires, now + 10 * 60_000),
|
|
75
|
+
};
|
|
76
|
+
fs.writeFileSync(approvalPath(id, env), JSON.stringify(record) + '\n', { flag: 'wx', mode: 0o600 });
|
|
77
|
+
return { planId: id, projectId: record.projectId, vmName: record.vmName, estimatedMonthlyRub: price, maxMonthlyRub, expiresAt: record.expiresAt,
|
|
78
|
+
...(plan.static_site ? { staticSite: { source: plan.static_site.source, sha256: plan.static_site.sha256, sizeBytes: plan.static_site.size_bytes,
|
|
79
|
+
files: plan.static_site.files.map((file) => ({ path: file.path, sha256: file.sha256, sizeBytes: file.size_bytes })),
|
|
80
|
+
ingress: 'dedicated security group: TCP 80 from 0.0.0.0/0', estimateScope: 'compute and disk only; public IP charges excluded' } } : {}) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function consumeApproval(id, env = process.env, now = Date.now()) {
|
|
84
|
+
const { plan, hash, price } = readPlan(id, env, now);
|
|
85
|
+
const credentials = readCredentials(env);
|
|
86
|
+
const file = approvalPath(id, env);
|
|
87
|
+
const a = readPrivateJson(file);
|
|
88
|
+
if (a.planId !== id || a.planHash !== hash || a.projectId !== plan.project_id || a.projectId !== credentials.projectId || a.siteMode !== !!plan.static_site ||
|
|
89
|
+
a.vmName !== plan.vm.name || !Number.isFinite(a.maxMonthlyRub) || price > a.maxMonthlyRub ||
|
|
90
|
+
!Number.isFinite(a.expiresAt) || a.expiresAt <= now) throw new Error('approval does not match current plan, project, cost or time');
|
|
91
|
+
const spent = path.join(approvalRoot(env), `${id}.used-${crypto.randomBytes(8).toString('hex')}.json`);
|
|
92
|
+
fs.renameSync(file, spent); // one caller consumes the pending path; a replay cannot.
|
|
93
|
+
return a;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function recordDeployedVM(approval, result, env = process.env, now = Date.now()) {
|
|
97
|
+
const vmId = result && result.vm_id;
|
|
98
|
+
if (!UUID.test(vmId || '') || result.status !== 'running') throw new Error('deploy did not return a running VM UUID');
|
|
99
|
+
const root = approvalRoot(env);
|
|
100
|
+
ensurePrivateDir(root);
|
|
101
|
+
const session = { planId: approval.planId, projectId: approval.projectId, vmId, sshUser: approval.sshUser, expiresAt: now + 2 * 60 * 60_000 };
|
|
102
|
+
fs.writeFileSync(path.join(root, `${approval.planId}.session.json`), JSON.stringify(session) + '\n', { flag: 'wx', mode: 0o600 });
|
|
103
|
+
return session;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function approvedVM(requestedId, env = process.env, now = Date.now()) {
|
|
107
|
+
const root = approvalRoot(env);
|
|
108
|
+
if (!fs.existsSync(root)) throw new Error('no approved deployment session');
|
|
109
|
+
ensurePrivateDir(root);
|
|
110
|
+
const sessions = fs.readdirSync(root).filter((name) => /^plan-[0-9a-f]{12}\.session\.json$/.test(name));
|
|
111
|
+
const credentials = readCredentials(env);
|
|
112
|
+
if (requestedId && !UUID.test(requestedId)) throw new Error('vm_exec requires an approved VM UUID');
|
|
113
|
+
const live = sessions.map((name) => readPrivateJson(path.join(root, name)))
|
|
114
|
+
.filter((s) => s.expiresAt > now && s.projectId === credentials.projectId && UUID.test(s.vmId || ''));
|
|
115
|
+
const matching = requestedId ? live.filter((s) => s.vmId === requestedId) : live;
|
|
116
|
+
if (matching.length !== 1) throw new Error('vm_exec requires exactly one matching active approved VM session');
|
|
117
|
+
return matching[0];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = { stateRoot, approvalRoot, planPath, readPlan, issueApproval, consumeApproval, recordDeployedVM, approvedVM };
|
package/src/install.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// Per-target installer —
|
|
3
|
-
// live-probe discipline) + ADR-004 brake emission (permission ask/deny + PreToolUse veto).
|
|
2
|
+
// Per-target installer — Claude settings plus guarded MCP registrations for other hosts.
|
|
4
3
|
//
|
|
5
4
|
// Tier map (ADR-005 Decision, applied verbatim):
|
|
6
5
|
// claude-code FULL candidate — full install path (this module emits it)
|
|
7
|
-
// codex
|
|
8
|
-
//
|
|
9
|
-
//
|
|
6
|
+
// codex / opencode / pi — project-scoped, pinned read-only MCP proxy;
|
|
7
|
+
// host activation still needs a live probe
|
|
8
|
+
// hermes guarded MCP entry in $HERMES_HOME/config.yaml (complex YAML refused)
|
|
9
|
+
// openclaude / cursor / windsurf / copilot
|
|
10
10
|
// DEGRADED-until-proven — installer REFUSES (no live probe delivered yet)
|
|
11
11
|
// agents-md / gemini
|
|
12
12
|
// POINTER-ONLY — ≤2048-char pointer + no-veto warning, never a fake install
|
|
@@ -16,18 +16,25 @@
|
|
|
16
16
|
// (ADR-004/ADR-005: "наличие файла хука — не evidence"; layout ≠ registration).
|
|
17
17
|
|
|
18
18
|
const fs = require('fs');
|
|
19
|
+
const os = require('os');
|
|
19
20
|
const path = require('path');
|
|
21
|
+
const { isDeepStrictEqual } = require('util');
|
|
20
22
|
const { spawnSync } = require('child_process');
|
|
21
|
-
const
|
|
23
|
+
const YAML = require('yaml');
|
|
24
|
+
const { permissionRules, loadClassification } = require('./classification');
|
|
25
|
+
const { resolveEngine } = require('./resolve');
|
|
26
|
+
const { selectedToolNames } = require('./toolsets');
|
|
22
27
|
|
|
23
28
|
const HOOK_TEMPLATE = path.join(__dirname, '..', 'templates', '.claude', 'hooks', 'cloudru-ssh-guard.cjs');
|
|
24
29
|
const POINTER_TEMPLATE = path.join(__dirname, '..', 'data', 'pointer-section.md');
|
|
25
30
|
|
|
26
31
|
const TIERS = {
|
|
27
32
|
'claude-code': 'full',
|
|
28
|
-
codex: '
|
|
33
|
+
codex: 'safe-mcp',
|
|
34
|
+
hermes: 'safe-mcp',
|
|
35
|
+
opencode: 'safe-mcp',
|
|
36
|
+
pi: 'safe-mcp',
|
|
29
37
|
openclaude: 'degraded',
|
|
30
|
-
opencode: 'degraded',
|
|
31
38
|
cursor: 'degraded',
|
|
32
39
|
windsurf: 'degraded',
|
|
33
40
|
copilot: 'degraded',
|
|
@@ -37,7 +44,6 @@ const TIERS = {
|
|
|
37
44
|
|
|
38
45
|
const DEGRADED_REASONS = {
|
|
39
46
|
openclaude: 'all three legs exist upstream (.mcp.json, PreToolUse deny, .openclaude/skills) but NOTHING has been probed live — degraded-until-proven (ADR-005).',
|
|
40
|
-
opencode: 'MCP + veto plugins are documented, zero live runs; R4 (tool budget deferral) unverified (ADR-005).',
|
|
41
47
|
cursor: 'veto hooks documented only; R3 broken by the .mdc adapter, R4 truncation history (~40 tools) unresolved (ADR-005).',
|
|
42
48
|
windsurf: 'R4 FAILS as-is: Cascade budget 100 tools < 144 — needs the engine tool-profile before any delivery (ADR-005).',
|
|
43
49
|
copilot: 'only Copilot CLI is a candidate; the coding agent fails R2 by construction ("will not ask for your approval" + hook timeout fail-open) (ADR-005).',
|
|
@@ -118,6 +124,221 @@ function writeJson(file, obj) {
|
|
|
118
124
|
fs.writeFileSync(file, JSON.stringify(obj, null, 2) + '\n');
|
|
119
125
|
}
|
|
120
126
|
|
|
127
|
+
const SAFE_SERVER = 'cloudru_hub';
|
|
128
|
+
const longRunningProfile = (toolsets) => toolsets === 'vm-deploy' || toolsets === 'k8s-deploy';
|
|
129
|
+
const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
130
|
+
|
|
131
|
+
function safeCommand(toolsets) {
|
|
132
|
+
return { command: process.execPath, args: [path.join(__dirname, '..', 'bin', 'cloudru-hub.js'), 'mcp-safe', '--toolsets', toolsets] };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function installClaudeK8s(projectDir, opts = {}) {
|
|
136
|
+
if (opts.npx) return { code: 2, lines: ['cloudru-hub install: k8s-deploy requires the local guarded launcher; npx package publication is not assumed.'] };
|
|
137
|
+
const file = path.join(projectDir, '.mcp.json');
|
|
138
|
+
const settingsFile = path.join(projectDir, '.claude', 'settings.json');
|
|
139
|
+
if ((fs.existsSync(file) && !readJson(file)) || (fs.existsSync(settingsFile) && !readJson(settingsFile))) {
|
|
140
|
+
return { code: 2, lines: ['cloudru-hub install: existing Claude JSON is invalid; refusing to replace it.'] };
|
|
141
|
+
}
|
|
142
|
+
const mcp = readJson(file) || {};
|
|
143
|
+
if (!record(mcp)) return { code: 2, lines: ['cloudru-hub install: Claude MCP JSON root must be an object.'] };
|
|
144
|
+
if (mcp.mcpServers && !record(mcp.mcpServers)) {
|
|
145
|
+
return { code: 2, lines: ['cloudru-hub install: existing Claude MCP server map is invalid.'] };
|
|
146
|
+
}
|
|
147
|
+
const servers = mcp.mcpServers || {};
|
|
148
|
+
if (servers['cloudru-vm'] || servers.cloudru_vm) return { code: 2, lines: ['cloudru-hub install: raw Cloud.ru MCP server is present; remove it from this isolated project before enabling k8s-deploy.'] };
|
|
149
|
+
const cmd = safeCommand('k8s-deploy');
|
|
150
|
+
const desired = { command: cmd.command, args: cmd.args };
|
|
151
|
+
if (servers[SAFE_SERVER] && !isDeepStrictEqual(servers[SAFE_SERVER], desired)) {
|
|
152
|
+
return { code: 2, lines: ['cloudru-hub install: existing cloudru_hub server differs from the guarded k8s-deploy command.'] };
|
|
153
|
+
}
|
|
154
|
+
const settings = readJson(settingsFile) || {};
|
|
155
|
+
if (!record(settings)) return { code: 2, lines: ['cloudru-hub install: Claude settings JSON root must be an object.'] };
|
|
156
|
+
if (settings.enabledMcpjsonServers && !Array.isArray(settings.enabledMcpjsonServers)) {
|
|
157
|
+
return { code: 2, lines: ['cloudru-hub install: existing Claude enabled server list is invalid.'] };
|
|
158
|
+
}
|
|
159
|
+
if (settings.enabledMcpjsonServers?.some((name) => name === 'cloudru-vm' || name === 'cloudru_vm')) {
|
|
160
|
+
return { code: 2, lines: ['cloudru-hub install: raw Cloud.ru MCP server is enabled in Claude settings; use an isolated project.'] };
|
|
161
|
+
}
|
|
162
|
+
mcp.mcpServers = { ...servers, [SAFE_SERVER]: desired };
|
|
163
|
+
writeJson(file, mcp);
|
|
164
|
+
writeJson(settingsFile, { ...settings, enabledMcpjsonServers: unionSorted(settings.enabledMcpjsonServers, [SAFE_SERVER]) });
|
|
165
|
+
return { code: 0, lines: [`cloudru-hub install: guarded k8s-deploy server written to ${file}.`,
|
|
166
|
+
' Claude Code activation and actual agent calls remain unverified until inference access returns.'] };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function installJsonMcp(target, projectDir, toolsets) {
|
|
170
|
+
const opencode = target === 'opencode';
|
|
171
|
+
const file = path.join(projectDir, opencode ? 'opencode.json' : '.pi/mcp.json');
|
|
172
|
+
if (opencode && fs.existsSync(path.join(projectDir, 'opencode.jsonc'))) {
|
|
173
|
+
return { code: 2, lines: ['cloudru-hub install: REFUSED — opencode.jsonc exists; merge the safe server manually to avoid competing project configs.'] };
|
|
174
|
+
}
|
|
175
|
+
let config = {};
|
|
176
|
+
if (fs.existsSync(file)) {
|
|
177
|
+
try { config = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
178
|
+
catch { return { code: 2, lines: [`cloudru-hub install: REFUSED — ${file} is not strict JSON; no changes written.`] }; }
|
|
179
|
+
}
|
|
180
|
+
if (!config || Array.isArray(config) || typeof config !== 'object') return { code: 2, lines: ['cloudru-hub install: REFUSED — config root is not an object.'] };
|
|
181
|
+
const parent = opencode ? config.mcp : config.mcpServers;
|
|
182
|
+
if (parent != null && (Array.isArray(parent) || typeof parent !== 'object')) return { code: 2, lines: ['cloudru-hub install: REFUSED — MCP config is not an object.'] };
|
|
183
|
+
const servers = parent || {};
|
|
184
|
+
if (servers['cloudru-vm'] || servers.cloudru_vm) return { code: 2, lines: ['cloudru-hub install: REFUSED — a raw cloudru-vm server already exists; remove it before enabling the safe server.'] };
|
|
185
|
+
const cmd = safeCommand(toolsets);
|
|
186
|
+
const entry = opencode
|
|
187
|
+
? { type: 'local', command: [cmd.command, ...cmd.args], enabled: true, ...(longRunningProfile(toolsets) ? { timeout: 1_200_000 } : {}) }
|
|
188
|
+
: { ...cmd, enabled: true, ...(longRunningProfile(toolsets) ? { timeout: 1200 } : {}), description: toolsets === 'vm-deploy' ? 'Pinned Cloud.ru VM deployment with local plan approval' : toolsets === 'k8s-deploy' ? 'Pinned Cloud.ru Kubernetes deployment with exact-manifest approval' : 'Pinned Cloud.ru MCP tools with cloud mutations blocked' };
|
|
189
|
+
if (servers[SAFE_SERVER] && JSON.stringify(servers[SAFE_SERVER]) !== JSON.stringify(entry)) {
|
|
190
|
+
return { code: 2, lines: [`cloudru-hub install: REFUSED — ${SAFE_SERVER} already has a different configuration.`] };
|
|
191
|
+
}
|
|
192
|
+
servers[SAFE_SERVER] = entry;
|
|
193
|
+
if (opencode) config.mcp = servers;
|
|
194
|
+
else config.mcpServers = servers;
|
|
195
|
+
writeJson(file, config);
|
|
196
|
+
return { code: 0, lines: [`cloudru-hub install: wrote ${file} with ${SAFE_SERVER} → mcp-safe.`, ' Activation requires a trusted project and a host-side connection probe; no live host claim made.'] };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function installCodex(projectDir, toolsets) {
|
|
200
|
+
const file = path.join(projectDir, '.codex', 'config.toml');
|
|
201
|
+
const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
202
|
+
const cmd = safeCommand(toolsets);
|
|
203
|
+
const tools = selectedToolNames(toolsets);
|
|
204
|
+
const inheritedEnv = longRunningProfile(toolsets)
|
|
205
|
+
? ['CLOUDRU_VM_BIN', 'CLOUDRU_HUB_CREDENTIALS', 'CLOUDRU_VM_HOME', 'CLOUDRU_HUB_APPROVALS']
|
|
206
|
+
: ['CLOUDRU_VM_BIN'];
|
|
207
|
+
if (tools.some((name) => name.startsWith('billing_'))) {
|
|
208
|
+
for (const name of ['CLOUDRU_HUB_CREDENTIALS', 'CLOUDRU_AGREEMENT_ID', 'CLOUDRU_KEY_ID', 'CLOUDRU_SECRET', 'CLOUDRU_PROJECT_ID', 'CLOUDRU_REGION']) {
|
|
209
|
+
if (!inheritedEnv.includes(name)) inheritedEnv.push(name);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const entry = `# cloudru-hub safe BEGIN\n[mcp_servers.${SAFE_SERVER}]\ncommand = ${JSON.stringify(cmd.command)}\nargs = ${JSON.stringify(cmd.args)}\nenv_vars = ${JSON.stringify(inheritedEnv)}\nrequired = true\nenabled_tools = ${JSON.stringify(tools)}\ndefault_tools_approval_mode = ${JSON.stringify(longRunningProfile(toolsets) ? 'auto' : 'prompt')}\n${longRunningProfile(toolsets) ? 'tool_timeout_sec = 1200\n' : ''}# cloudru-hub safe END\n`;
|
|
213
|
+
// TOML has quoted/dotted keys and inline tables. A regex that checks only a
|
|
214
|
+
// bare section name can leave a raw server enabled alongside this one. The
|
|
215
|
+
// zero-dependency installer therefore owns its exact block and refuses any
|
|
216
|
+
// other MCP declaration for manual review rather than guessing its meaning.
|
|
217
|
+
const remainder = existing.includes(entry) ? existing.replace(entry, '') : existing;
|
|
218
|
+
if (/mcp_servers|^\s*(?:\[\s*["']|["'][^"']+["']\s*=)|\\u[0-9a-f]{4}/im.test(remainder)) {
|
|
219
|
+
return { code: 2, lines: ['cloudru-hub install: REFUSED — existing MCP/TOML syntax needs manual review before adding a safe server.'] };
|
|
220
|
+
}
|
|
221
|
+
if (remainder !== existing) return { code: 0, lines: [`cloudru-hub install: ${file} already contains the safe server.`] };
|
|
222
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
223
|
+
fs.writeFileSync(file, existing.trimEnd() + '\n' + entry);
|
|
224
|
+
return { code: 0, lines: [`cloudru-hub install: wrote ${file} with ${tools.length} classified tools and mcp-safe.`, ' Codex loads project config only in a trusted project; run a live host probe before claiming activation.'] };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function hermesBlock(toolsets, enginePath, indent = ' ') {
|
|
228
|
+
const cmd = safeCommand(toolsets);
|
|
229
|
+
const include = selectedToolNames(toolsets);
|
|
230
|
+
return [
|
|
231
|
+
`${indent}# cloudru-hub safe BEGIN`,
|
|
232
|
+
`${indent}${SAFE_SERVER}:`,
|
|
233
|
+
`${indent}${indent}command: ${JSON.stringify(cmd.command)}`,
|
|
234
|
+
`${indent}${indent}args: ${JSON.stringify(cmd.args)}`,
|
|
235
|
+
`${indent}${indent}env:`,
|
|
236
|
+
`${indent}${indent}${indent}CLOUDRU_VM_BIN: ${JSON.stringify(enginePath)}`,
|
|
237
|
+
`${indent}${indent}enabled: true`,
|
|
238
|
+
`${indent}${indent}timeout: ${longRunningProfile(toolsets) ? 1200 : 120}`,
|
|
239
|
+
`${indent}${indent}tools:`,
|
|
240
|
+
`${indent}${indent}${indent}include: [${include.map((name) => JSON.stringify(name)).join(', ')}]`,
|
|
241
|
+
`${indent}# cloudru-hub safe END`,
|
|
242
|
+
].join('\n') + '\n';
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function installHermes(toolsets, opts = {}) {
|
|
246
|
+
const home = path.resolve(opts.hermesHome || process.env.HERMES_HOME || path.join(os.homedir(), '.hermes'));
|
|
247
|
+
const file = path.join(home, 'config.yaml');
|
|
248
|
+
const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
249
|
+
const refuse = (reason) => ({ code: 2, lines: [`cloudru-hub install: REFUSED — ${reason}; ${file} was not changed.`] });
|
|
250
|
+
let original;
|
|
251
|
+
let before;
|
|
252
|
+
try {
|
|
253
|
+
original = YAML.parseDocument(existing, { uniqueKeys: true });
|
|
254
|
+
if (original.errors.length || (original.contents && (!YAML.isMap(original.contents) || original.contents.flow))) {
|
|
255
|
+
return refuse('existing YAML is invalid or not a block mapping');
|
|
256
|
+
}
|
|
257
|
+
before = original.toJS() || {};
|
|
258
|
+
if (before.mcp_servers != null && (Array.isArray(before.mcp_servers) || typeof before.mcp_servers !== 'object')) {
|
|
259
|
+
return refuse('mcp_servers is not a mapping');
|
|
260
|
+
}
|
|
261
|
+
} catch { return refuse('existing YAML could not be parsed'); }
|
|
262
|
+
if (/\t|^---\s*$|^\.\.\.\s*$/m.test(existing)) return refuse('complex YAML needs manual review');
|
|
263
|
+
const sections = [...existing.matchAll(/^mcp_servers:\s*(?:#.*)?$/gm)];
|
|
264
|
+
if (sections.length > 1) return refuse('duplicate mcp_servers sections');
|
|
265
|
+
if (!sections.length && /^mcp_servers\s*:/m.test(existing)) return refuse('mcp_servers is not a plain top-level mapping');
|
|
266
|
+
if (!sections.length && /(?:^|\n)\s*["']?mcp_servers["']?\s*:/m.test(existing)) return refuse('complex mcp_servers syntax needs manual review');
|
|
267
|
+
// YAML duplicate keys may be accepted with last-value-wins semantics. A
|
|
268
|
+
// quoted second mcp_servers key could hide the server we just inserted.
|
|
269
|
+
if ((existing.match(/mcp_servers/g) || []).length !== sections.length || /\\[uxU][0-9a-f]+/i.test(existing)) {
|
|
270
|
+
return refuse('duplicate or escaped YAML keys need manual review');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let indent = ' ';
|
|
274
|
+
let insertAt = existing.length;
|
|
275
|
+
let blockText = '';
|
|
276
|
+
if (sections.length) {
|
|
277
|
+
const section = sections[0];
|
|
278
|
+
const contentStart = section.index + section[0].length + (existing[section.index + section[0].length] === '\n' ? 1 : 0);
|
|
279
|
+
const next = /^\S[^\n]*$/gm;
|
|
280
|
+
next.lastIndex = contentStart;
|
|
281
|
+
const match = next.exec(existing);
|
|
282
|
+
insertAt = match ? match.index : existing.length;
|
|
283
|
+
blockText = existing.slice(contentStart, insertAt);
|
|
284
|
+
const indents = [...blockText.matchAll(/^([ ]+)(\S.*)$/gm)]
|
|
285
|
+
.filter((m) => !m[2].startsWith('#')).map((m) => m[1].length);
|
|
286
|
+
if (indents.length) {
|
|
287
|
+
const min = Math.min(...indents);
|
|
288
|
+
indent = ' '.repeat(min);
|
|
289
|
+
if (min > 8) return refuse('unusual mcp_servers indentation');
|
|
290
|
+
}
|
|
291
|
+
} else if (/\b(?:cloudru[-_]vm|cloudru_hub)\b/i.test(existing)) {
|
|
292
|
+
return refuse('Cloud.ru configuration is already mentioned');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Hermes deliberately strips non-baseline environment variables before
|
|
296
|
+
// starting MCP servers. Resolve the engine now and pass only its path in
|
|
297
|
+
// the server-specific env; the proxy hashes the file again at runtime.
|
|
298
|
+
const engine = opts.resolvedEngine || resolveEngine();
|
|
299
|
+
if (engine.error || !engine.verified || !engine.path || engine.sha256 !== loadClassification().engineSha256) {
|
|
300
|
+
return refuse('a locally available, hash-verified Cloud.ru engine is required for Hermes');
|
|
301
|
+
}
|
|
302
|
+
const enginePath = path.resolve(engine.path);
|
|
303
|
+
const block = hermesBlock(toolsets, enginePath, indent);
|
|
304
|
+
if (/\b(?:cloudru[-_]vm|cloudru_hub)\b/i.test(existing.replace(block, ''))) {
|
|
305
|
+
return refuse('a Cloud.ru server is already mentioned outside this exact safe block');
|
|
306
|
+
}
|
|
307
|
+
if (blockText.includes('# cloudru-hub safe BEGIN')) {
|
|
308
|
+
const remainder = blockText.replace(block, '');
|
|
309
|
+
if (remainder !== blockText && !/\b(?:cloudru[-_]vm|cloudru_hub)\b|[&*]|<<\s*:/i.test(remainder)) {
|
|
310
|
+
return { code: 0, lines: [`cloudru-hub install: ${file} already contains the safe server.`] };
|
|
311
|
+
}
|
|
312
|
+
return refuse('existing cloudru-hub block differs from the requested toolset');
|
|
313
|
+
}
|
|
314
|
+
if (/\b(?:cloudru[-_]vm|cloudru_hub)\b|[&*]|<<\s*:/i.test(blockText)) return refuse('Cloud.ru server or YAML aliases already occur in mcp_servers');
|
|
315
|
+
const updated = sections.length
|
|
316
|
+
? existing.slice(0, insertAt) + (insertAt && existing[insertAt - 1] !== '\n' ? '\n' : '') + block + existing.slice(insertAt)
|
|
317
|
+
: existing.trimEnd() + (existing.trim() ? '\n\n' : '') + 'mcp_servers:\n' + block;
|
|
318
|
+
try {
|
|
319
|
+
const candidate = YAML.parseDocument(updated, { uniqueKeys: true });
|
|
320
|
+
if (candidate.errors.length) return refuse('generated YAML failed syntax validation');
|
|
321
|
+
const cmd = safeCommand(toolsets);
|
|
322
|
+
const expected = {
|
|
323
|
+
...before,
|
|
324
|
+
mcp_servers: {
|
|
325
|
+
...(before.mcp_servers || {}),
|
|
326
|
+
[SAFE_SERVER]: { command: cmd.command, args: cmd.args, env: { CLOUDRU_VM_BIN: enginePath }, enabled: true, timeout: longRunningProfile(toolsets) ? 1200 : 120, tools: { include: selectedToolNames(toolsets) } },
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
if (!isDeepStrictEqual(candidate.toJS(), expected)) return refuse('generated YAML would change other configuration');
|
|
330
|
+
} catch { return refuse('generated YAML could not be validated'); }
|
|
331
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
332
|
+
const tmp = path.join(home, `.config.yaml.cloudru-hub-${process.pid}.tmp`);
|
|
333
|
+
try {
|
|
334
|
+
fs.writeFileSync(tmp, updated, { flag: 'wx', mode: fs.existsSync(file) ? fs.statSync(file).mode & 0o777 : 0o600 });
|
|
335
|
+
fs.renameSync(tmp, file);
|
|
336
|
+
} finally {
|
|
337
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
338
|
+
}
|
|
339
|
+
return { code: 0, lines: [`cloudru-hub install: wrote ${file} with ${selectedToolNames(toolsets).length} classified tools and mcp-safe.`, ' Restart Hermes and inspect loaded MCP tools before claiming activation.'] };
|
|
340
|
+
}
|
|
341
|
+
|
|
121
342
|
/**
|
|
122
343
|
* Execute the emitted brake against the ADR-004 acid fixtures and REQUIRE the veto.
|
|
123
344
|
* Returns {ok, results:[{fixture, expected, got}]}. An installer that skips this and
|
|
@@ -141,16 +362,23 @@ function probeBrake(hookPath) {
|
|
|
141
362
|
return { ok, results };
|
|
142
363
|
}
|
|
143
364
|
|
|
144
|
-
/**
|
|
145
|
-
* Install into a project dir for a target. Pure I/O orchestration; returns
|
|
146
|
-
* {code, lines:[...]} — code 0 success, 2 refused (degraded), 3 plan-only, 1 failure.
|
|
147
|
-
*/
|
|
365
|
+
/** Install files or print guarded setup instructions; code 0 emitted, 2 refused, 3 manual setup. */
|
|
148
366
|
function install(target, projectDir, opts = {}) {
|
|
149
367
|
const lines = [];
|
|
150
368
|
const tier = TIERS[target];
|
|
151
369
|
if (!tier) {
|
|
152
370
|
return { code: 1, lines: [`cloudru-hub install: unknown target "${target}" (known: ${Object.keys(TIERS).join(', ')})`] };
|
|
153
371
|
}
|
|
372
|
+
const toolsets = opts.toolsets || 'inventory';
|
|
373
|
+
if (target === 'claude-code' && toolsets === 'k8s-deploy') {
|
|
374
|
+
try { selectedToolNames(toolsets); }
|
|
375
|
+
catch (error) { return { code: 1, lines: [`cloudru-hub install: ${error.message}`] }; }
|
|
376
|
+
return installClaudeK8s(projectDir, opts);
|
|
377
|
+
}
|
|
378
|
+
if (['codex', 'opencode', 'pi', 'hermes'].includes(target)) {
|
|
379
|
+
try { selectedToolNames(toolsets); }
|
|
380
|
+
catch (error) { return { code: 1, lines: [`cloudru-hub install: ${error.message}`] }; }
|
|
381
|
+
}
|
|
154
382
|
|
|
155
383
|
if (tier === 'degraded') {
|
|
156
384
|
return {
|
|
@@ -178,20 +406,10 @@ function install(target, projectDir, opts = {}) {
|
|
|
178
406
|
};
|
|
179
407
|
}
|
|
180
408
|
|
|
181
|
-
if (tier === '
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
return
|
|
185
|
-
code: 3,
|
|
186
|
-
lines: [
|
|
187
|
-
'cloudru-hub install: target "codex" — plan printed, NOT auto-applied (ADR-005: delivery requires an EXECUTED veto probe).',
|
|
188
|
-
' 1. codex mcp add cloudru-vm -- cloudru-hub mcp # registers the stdio server in ~/.codex/config.toml',
|
|
189
|
-
` 2. Merge into ~/.codex/hooks.json (user-global ONLY — project hooks.json is a measured silent no-op):`,
|
|
190
|
-
` {"hooks":{"preToolUse":[{"command":"node ${HOOK_TEMPLATE}"}]}}`,
|
|
191
|
-
' 3. MANDATORY probe: in a codex session run `sshpass -p x ssh user1@1.2.3.4` and CONFIRM the block message.',
|
|
192
|
-
' A hook file on disk is NOT evidence (health-advisor 1.2.0 lesson: layout ≠ registration).',
|
|
193
|
-
],
|
|
194
|
-
};
|
|
409
|
+
if (tier === 'safe-mcp') {
|
|
410
|
+
if (target === 'codex') return installCodex(projectDir, toolsets);
|
|
411
|
+
if (target === 'hermes') return installHermes(toolsets, opts);
|
|
412
|
+
return installJsonMcp(target, projectDir, toolsets);
|
|
195
413
|
}
|
|
196
414
|
|
|
197
415
|
// ── claude-code FULL path ──
|
|
@@ -248,5 +466,6 @@ module.exports = {
|
|
|
248
466
|
mergeHookEvents,
|
|
249
467
|
claudeSettingsFragment,
|
|
250
468
|
probeBrake,
|
|
469
|
+
installClaudeK8s,
|
|
251
470
|
install,
|
|
252
471
|
};
|